diff --git a/lang/en.json b/lang/en.json index b65cade7..dce63536 100755 --- a/lang/en.json +++ b/lang/en.json @@ -3258,7 +3258,8 @@ "knowTheTide": "Know The Tide gained a token", "lackingItemTransferPermission": "User {user} lacks owner permission needed to transfer items to {target}", "noTokenTargeted": "No token is targeted", - "behaviorRegionRequiresGM": "Creating a Region with an attached Behavior requires an online GM" + "behaviorRegionRequiresGM": "Creating a Region with an attached Behavior requires an online GM", + "comboDiceOnlyTwoDiceError": "Combo dice functionality only works on a single pair of two dice" }, "Progress": { "migrationLabel": "Performing system migration. Please wait and do not close Foundry." diff --git a/module/dice/_module.mjs b/module/dice/_module.mjs index e1206f82..6a5724bd 100644 --- a/module/dice/_module.mjs +++ b/module/dice/_module.mjs @@ -4,4 +4,5 @@ export { default as DamageRoll } from './damageRoll.mjs'; export { default as DHRoll } from './dhRoll.mjs'; export { default as DualityRoll } from './dualityRoll.mjs'; export { default as FateRoll } from './fateRoll.mjs'; +export { BaseDie } from './die/_module.mjs'; export { diceTypes } from './die/_module.mjs'; diff --git a/module/dice/die/baseDie.mjs b/module/dice/die/baseDie.mjs index cc88f753..4c684dd3 100644 --- a/module/dice/die/baseDie.mjs +++ b/module/dice/die/baseDie.mjs @@ -1,5 +1,14 @@ +import { adjustDice, triggerChatRollFx } from '../../helpers/utils.mjs'; + export default class BaseDie extends foundry.dice.terms.Die { - async rerollResult(resultIndex) { + static MODIFIERS = { + ...foundry.dice.terms.Die.MODIFIERS, + cc: 'compoundComboDice', + c: 'comboDice' + }; + + async rerollResult(resultToReroll) { + const resultIndex = Number(resultToReroll); const result = this.results[resultIndex]; result.rerolled = true; result.active = false; @@ -8,5 +17,199 @@ export default class BaseDie extends foundry.dice.terms.Die { const rerolledResult = this.results[this.results.length - 1]; this.results.splice(this.results.length - 1, 1); this.results.splice(resultIndex, 0, rerolledResult); + + if (['c', 'cc'].some(x => this.modifiers.includes(x))) { + await this.handleComboDiceReroll(resultIndex, result); + } + } + + /** @override */ + getResultCSS(result) { + const hasSuccess = result.success !== undefined; + const hasFailure = result.failure !== undefined; + const isMax = result.result === this.faces; + const isMin = result.result === 1; + return [ + this.constructor.name.toLowerCase(), + result.denomination ?? this.denomination, // Accomodating ComboDie as a result can have a different denomination than the die as a whole + result.success ? 'success' : null, + result.failure ? 'failure' : null, + result.rerolled ? 'rerolled' : null, + result.exploded ? 'exploded' : null, + result.discarded ? 'discarded' : null, + !(hasSuccess || hasFailure) && isMin ? 'min' : null, + !(hasSuccess || hasFailure) && isMax ? 'max' : null + ]; + } + + /* -------------------------------------------- */ + /* Modifier Logic */ + /* -------------------------------------------- */ + + async compoundComboDice() { + return this.handleComboDice({ maxIncreasesDiceSize: true}); + } + + async comboDice() { + return this.handleComboDice({ maxIncreasesDiceSize: false }); + } + + async handleComboDice(maxIncreasesDiceSize) { + /* ComboDice only works with exactly two dice and both have to be the same denomination */ + if (this.number !== 2) { + ui.notifications.warn(game.i18n.localize('DAGGERHEART.UI.Notifications.comboDiceOnlyTwoDiceError')); + return false; + } + + return this.rollComboDice(maxIncreasesDiceSize); + } + + async rollComboDice(options) { + const { maxIncreasesDiceSize, rerollStartIndex } = options; + const initialResultsLength = this.results.filter(x => x.active).length; + const result = await this.continueCombo(maxIncreasesDiceSize); + + /* The flow of DiceSoNice has no way of knowing that some of the results of a Die should be a different denomination + We solve this by marking the results as hidden so they're not picked up by the auto roll of DiceSoNice. + The actual rolls are done here in place so every dice gets the correct denomination. + */ + if (game.modules.get('dice-so-nice')?.active) { + const resultsToRoll = this.results.filter((x, index) => { + if (!x.active) return false; + if (rerollStartIndex) + return index === rerollStartIndex || index > initialResultsLength - 1; + + return true; + }); + const rolls = []; + for (const result of resultsToRoll) { + const roll = await (new Roll(`1${result.denomination ?? this.denomination}`)).evaluate(); + roll.terms[0].results = [result]; + roll._evaluateTotal(); + rolls.push(roll); + } + + /* If there are other dice that will be rolled we cannot await here. The other dice will be awaited in the normal flow */ + if (rerollStartIndex === undefined && this._root.dice.length > 1) { + for (const roll of rolls) { + game.dice3d.showForRoll(roll, game.user, true); + } + } else { + await Promise.allSettled(rolls.map(roll => game.dice3d.showForRoll(roll, game.user, true))); + } + } + + for (const result of this.results) + result.hidden = true; + + const modifier = maxIncreasesDiceSize ? 'cc' : 'c'; + if (!this.modifiers.includes(modifier)) + this.modifiers.push(modifier); + + return result; + } + + async continueCombo(maxIncreasesDiceSize) { + const activeResults = this.results.filter(x => x.active); + const lastIndex = activeResults.length - 1; + const lastResult = activeResults[lastIndex]; + + /* The Combo only continues if the latest roll was higher or equal to the previous */ + if (lastResult.result < activeResults[lastIndex - 1].result) return false; + + const lastFaces = lastResult.denomination ? + Number(lastResult.denomination.slice(1)) : this.faces; + const currentDenomination = `d${lastFaces}` + const increaseDiceSize = + maxIncreasesDiceSize && lastFaces < 12 && + lastResult.result === lastFaces; + + const denomination = increaseDiceSize ? adjustDice(currentDenomination, false) : currentDenomination; + + const newRoll = await (new Roll(`1${denomination}`)).evaluate(); + this.results.push({ result: newRoll.total, denomination: denomination, active: true }); + this.number += 1; + + return this.continueCombo(maxIncreasesDiceSize); + } + + async handleComboDiceReroll(rerolledIndex, originalResult) { + /* Potentially sliced when correcting compound dice at (1) */ + let resultGroupingIndexes = this.results.map((x, index) => ({ index, active: x.active })) + .filter(x => x.active).map(x => x.index); + if (resultGroupingIndexes.length <= 1) return; + + const rerolledResult = this.results[rerolledIndex]; + const compoundCombo = this.modifiers.includes('cc'); + const rerollGroupingIndex = resultGroupingIndexes.indexOf(rerolledIndex); + + const previousIndex = resultGroupingIndexes[rerollGroupingIndex - 1]; + const previousResult = this.results[previousIndex]; + const nextIndex = resultGroupingIndexes[rerollGroupingIndex + 1]; + const nextResult = this.results[nextIndex]; + + const dropDice = preceeding => { + const cutoffIndex = preceeding ? resultGroupingIndexes[rerollGroupingIndex + 1] + 1 : rerolledIndex + 1; + this.results = this.results.slice(0, cutoffIndex); + this.number = this.results.filter(x => x.active).length; + }; + + /* (1) If subsequent size increases from the compound effect are now incorrect, drop subsequent dice */ + const lastFaces = Number((originalResult.denomination ?? this.denomination).slice(1)); + if ( + compoundCombo && + ( + (originalResult.result === lastFaces && + rerolledResult.result !== lastFaces) + || + (originalResult.result !== lastFaces && + rerolledResult.result === lastFaces) + ) + ) { + dropDice(false); + resultGroupingIndexes = resultGroupingIndexes.slice(0, this.number); + } + + /* (2) Rerolling any of the last two dice might introduce new results */ + if ( + rerollGroupingIndex === resultGroupingIndexes.length - 1 && + rerolledResult.result >= previousResult?.result + ) { + return await this.rollComboDice({ + maxIncreasesDiceSize: compoundCombo, + rerollStartIndex: rerollGroupingIndex + }); + } else if ( + rerollGroupingIndex === resultGroupingIndexes.length - 2 && + rerolledResult.result < nextResult?.result + ) { + return await this.rollComboDice({ + maxIncreasesDiceSize: compoundCombo, + rerollStartIndex: rerollGroupingIndex + }); + } + /* (3) Rerolling a subsequent dice might invalidate later dice which should then be dropped */ + else if (rerolledResult.result < previousResult?.result){ + dropDice(false); + } + /* (4) Rerolling a preceeding dice might invalidate later dice which should then be dropped */ + else if (rerolledResult.result >= nextResult?.result) { + dropDice(true); + } + + const fakeRollFaces = + rerolledResult.denomination ? rerolledResult.denomination.slice(1) : this.faces; + const fakeRoll = { + _evaluated: true, + dice: [new foundry.dice.terms.Die({ + ...this, + results: [rerolledResult], + total: rerolledResult.result, + faces: fakeRollFaces + })], + options: { appearance: {} } + }; + await triggerChatRollFx([fakeRoll]); + rerolledResult.hidden = true; } } \ No newline at end of file diff --git a/module/dice/die/dualityDie.mjs b/module/dice/die/dualityDie.mjs index 2f4dae77..1e51e851 100644 --- a/module/dice/die/dualityDie.mjs +++ b/module/dice/die/dualityDie.mjs @@ -1,5 +1,5 @@ -import BaseDie from './baseDie.mjs'; import { updateResourcesForDualityReroll } from '../helpers.mjs'; +import BaseDie from './baseDie.mjs'; export default class DualityDie extends BaseDie { constructor(options) { diff --git a/templates/ui/chat/parts/damage-part.hbs b/templates/ui/chat/parts/damage-part.hbs index 7d6d983d..8230f388 100644 --- a/templates/ui/chat/parts/damage-part.hbs +++ b/templates/ui/chat/parts/damage-part.hbs @@ -62,7 +62,7 @@ {{#if active}}