import { SYSTEM } from "../config/system.mjs" import D30Roll from "./d30-roll.mjs" /** * Prompt the user with a dialog to configure and execute a roll. * * @param {Object} options Configuration options for the roll. * @param {string} options.rollType The type of roll being performed (e.g., RESOURCE, DAMAGE, ATTACK, SAVE). * @param {string} options.rollValue The initial value or formula for the roll. * @param {string} options.rollTarget The target of the roll. * @param {"="|"+"|"++"|"-"|"--"} options.rollAdvantage If there is an avantage (+), a disadvantage (-), a double advantage (++), a double disadvantage (--) or a normal roll (=). * @param {string} options.actorId The ID of the actor performing the roll. * @param {string} options.actorName The name of the actor performing the roll. * @param {string} options.actorImage The image of the actor performing the roll. * @param {boolean} options.hasTarget Whether the roll has a target. * @param {Object} options.target The target of the roll, if any. * @param {Object} options.data Additional data for the roll. * * @returns {Promise} The roll result or null if the dialog was cancelled. */ export async function prompt(options = {}) { try { let dice = "1D20" let maxValue = 20 let baseFormula = "1D20" let modifierFormula = "1D0" let hasModifier = true let hasChangeDice = false let hasD30 = false let hasFavor = false let hasMaxValue = true let hasGrantedDice = false let pointBlank = false let letItFly = false let saveSpell = game.lethalFantasy?.spellDefense ?? false let beyondSkill = false let hasStaticModifier = false let hasExplode = true let actor = game.actors.get(options.actorId) if (options.rollType === "challenge" || options.rollType === "save") { options.rollName = game.i18n.localize(`LETHALFANTASY.Label.${options.rollTarget.rollKey}`) hasD30 = options.rollType === "save" if (options.rollTarget.rollKey === "dying") { dice = options.rollTarget.value hasModifier = false hasChangeDice = true hasFavor = true } else { dice = "1D20" hasFavor = true } } else if (options.rollType === "granted") { hasD30 = false options.rollName = `Granted ${options.rollTarget.rollKey}` dice = options.rollTarget.formula baseFormula = options.rollTarget.formula hasModifier = false hasMaxValue = false hasChangeDice = false hasFavor = false } else if (options.rollType === "monster-attack" || options.rollType === "monster-defense") { hasD30 = true options.rollName = options.rollTarget.name dice = "1D20" baseFormula = "D20" hasModifier = true hasChangeDice = false hasFavor = true if (options.rollType === "monster-attack") { options.rollTarget.value = options.rollTarget.attackModifier options.rollTarget.charModifier = 0 } else { options.rollTarget.value = options.rollTarget.defenseModifier options.rollTarget.charModifier = 0 options.isRangedDefense = options.rollTarget.isRangedDefense ?? false } } else if (options.rollType === "monster-skill") { options.rollName = game.i18n.localize(`LETHALFANTASY.Label.${options.rollTarget.rollKey}`) dice = "1D20" baseFormula = "D20" hasModifier = true hasFavor = true hasChangeDice = false } else if (options.rollType === "skill") { options.rollName = options.rollTarget.name hasD30 = true dice = "1D20" baseFormula = "D20" hasModifier = true hasFavor = true hasChangeDice = false options.rollTarget.value = Math.floor(options.rollTarget.system.skillTotal / 10) } else if (options.rollType === "weapon-attack" || options.rollType === "weapon-defense") { hasD30 = true options.rollName = options.rollTarget.name dice = "1D20" baseFormula = "D20" hasModifier = true hasChangeDice = false hasFavor = true if (options.rollType === "weapon-attack") { if (options.rollTarget.weapon.system.weaponType === "melee") { options.rollTarget.value = options.rollTarget.combat.attackModifier + options.rollTarget.weaponSkillModifier + options.rollTarget.weapon.system.bonuses.attackBonus options.rollTarget.charModifier = options.rollTarget.combat.attackModifier } else { options.rollTarget.value = options.rollTarget.combat.rangedAttackModifier + options.rollTarget.weaponSkillModifier + options.rollTarget.weapon.system.bonuses.attackBonus options.rollTarget.charModifier = options.rollTarget.combat.rangedAttackModifier } } else { // For defense, check if it's a ranged defense const defenseModifier = options.rollTarget.isRangedDefense ? options.rollTarget.combat.rangedDefenseModifier : options.rollTarget.combat.defenseModifier options.rollTarget.value = defenseModifier + options.rollTarget.weaponSkillModifier + options.rollTarget.weapon.system.bonuses.defenseBonus + options.rollTarget.armorDefense options.rollTarget.charModifier = defenseModifier // Store isRanged flag for D30 lookup options.isRangedDefense = options.rollTarget.isRangedDefense } } else if (options.rollType === "spell" || options.rollType === "spell-attack" || options.rollType === "spell-power") { hasD30 = true options.rollName = options.rollTarget.name dice = "1D20" baseFormula = "D20" hasModifier = true hasChangeDice = false hasFavor = true options.rollTarget.value = options.rollTarget.actorModifiers.levelSpellModifier + options.rollTarget.actorModifiers.intSpellModifier options.rollTarget.charModifier = options.rollTarget.actorModifiers.intSpellModifier hasStaticModifier = options.rollType === "spell-power" //hasModifier = options.rollType !== "spell-attack" if (hasStaticModifier) { options.rollTarget.staticModifier = options.rollTarget.actorLevel } else { options.rollTarget.staticModifier = 0 } } else if (options.rollType === "miracle" || options.rollType === "miracle-attack" || options.rollType === "miracle-power") { hasD30 = true options.rollName = options.rollTarget.name dice = "1D20" baseFormula = "D20" hasChangeDice = false hasFavor = true options.rollTarget.value = options.rollTarget.actorModifiers.levelMiracleModifier + options.rollTarget.actorModifiers.chaMiracleModifier options.rollTarget.charModifier = options.rollTarget.actorModifiers.chaMiracleModifier hasStaticModifier = options.rollType === "miracle-power" //hasModifier = options.rollType !== "miracle-attack" if (hasStaticModifier) { options.rollTarget.staticModifier = options.rollTarget.actorLevel } else { options.rollTarget.staticModifier = 0 } } else if (options.rollType === "shield-roll") { hasD30 = false options.rollName = "Shield Defense" dice = options.rollTarget.system.defense.toUpperCase() baseFormula = dice hasModifier = true hasChangeDice = false hasMaxValue = false hasExplode = false hasFavor = true options.rollTarget.value = 0 } else if (options.rollType.includes("weapon-damage")) { options.rollName = options.rollTarget.name options.isDamage = true hasModifier = true hasChangeDice = false let damageBonus = (options.rollTarget.weapon.system.applyStrengthDamageBonus) ? options.rollTarget.combat.damageModifier : 0 options.rollTarget.value = damageBonus + options.rollTarget.weaponSkillModifier + options.rollTarget.weapon.system.bonuses.damageBonus options.rollTarget.charModifier = damageBonus dice = options.rollTarget.weapon.system.damage.damageM if (/NE$/i.test(dice)) { hasMaxValue = false hasExplode = false } dice = dice.replace(/NE$/i, "").replace("E", "") baseFormula = dice } else if (options.rollType.includes("monster-damage")) { options.rollName = options.rollTarget.name options.isDamage = true hasModifier = true hasChangeDice = false options.rollTarget.value = options.rollTarget.damageModifier options.rollTarget.charModifier = 0 dice = options.rollTarget.damageDice dice = dice.replace("E", "") baseFormula = dice if (options.rollTarget.noExplode) { hasMaxValue = false hasExplode = false } } if (options.rollType === "save" && (options.rollTarget.rollKey === "pain" || options.rollTarget.rollKey === "paincourage")) { dice = options.rollTarget.rollDice baseFormula = options.rollTarget.rollDice hasModifier = false } const rollModes = foundry.utils.duplicate(CONFIG.ChatMessage.modes); const fieldRollMode = new foundry.data.fields.StringField({ choices: rollModes, blank: false, default: "public", }) const choiceModifier = SYSTEM.CHOICE_MODIFIERS const choiceDice = SYSTEM.CHOICE_DICE const choiceFavor = SYSTEM.FAVOR_CHOICES let modifier = "+0" let targetName // True for any ranged attack: PC weapon (ranged type) or monster attack (ranged mode) const isRangedAttack = (options.rollType === "weapon-attack" && options.rollTarget?.weapon?.system?.weaponType === "ranged") || (options.rollType === "monster-attack" && options.rollTarget?.attackMode === "ranged") let dialogContext = { rollType: options.rollType, rollTarget: options.rollTarget, rollName: options.rollName, actorName: options.actorName, rollModes, hasModifier, hasFavor, hasChangeDice, pointBlank, baseValue: options.rollTarget.value, attackerAimChoices: SYSTEM.ATTACKER_AIM_CHOICES, attackerAim: "0", changeDice: `${dice}`, fieldRollMode, choiceModifier, choiceDice, choiceFavor, baseFormula, dice, hasTarget: options.hasTarget, modifier, saveSpell, favor: "none", targetName, isRangedAttack } let rollContext if (options.rollContext) { rollContext = foundry.utils.duplicate(options.rollContext) hasGrantedDice = !!rollContext.hasGrantedDice pointBlank = !!rollContext.pointBlank beyondSkill = !!rollContext.beyondSkill letItFly = !!rollContext.letItFly saveSpell = !!rollContext.saveSpell const _rawMode = rollContext.rollMode || game.settings.get("core", "rollMode") const _modeMap = { publicroll: "public", gmroll: "gm", blindroll: "blind", selfroll: "self" } rollContext.visibility ||= _modeMap[_rawMode] ?? _rawMode ?? "public" rollContext.modifier ||= modifier rollContext.favor ||= "none" rollContext.changeDice ||= `${dice}` rollContext.attackerAim ||= "0" } else { const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/roll-dialog.hbs", dialogContext) let position = game.user.getFlag(SYSTEM.id, "roll-dialog-pos") || { top: -1, left: -1 } const label = game.i18n.localize("LETHALFANTASY.Roll.roll") rollContext = await foundry.applications.api.DialogV2.wait({ window: { title: "Roll dialog" }, classes: ["lethalfantasy"], content, position, buttons: [ { action: "roll", type: "button", label: label, callback: (event, button, dialog) => { log("Roll context", event, button, dialog) let position = dialog?.position game.user.setFlag(SYSTEM.id, "roll-dialog-pos", foundry.utils.duplicate(position)) const output = Array.from(button.form.elements).reduce((obj, input) => { if (input.name) obj[input.name] = input.value return obj }, {}) return output }, }, ], actions: { "selectGranted": (event, button) => { hasGrantedDice = event.target.checked }, "selectBeyondSkill": (event, button) => { beyondSkill = button.checked }, "selectPointBlank": (event, button) => { pointBlank = button.checked }, "selectLetItFly": (event, button) => { letItFly = button.checked }, "saveSpellCheck": (event, button) => { saveSpell = button.checked }, "gotoToken": (event, button) => { let tokenId = $(button).data("tokenId") let token = canvas.tokens?.get(tokenId) if (token) { canvas.animatePan({ x: token.x, y: token.y, duration: 200 }) canvas.tokens.releaseAll() token.control({ releaseOthers: true }) } } }, rejectClose: false // Click on Close button will not launch an error }) } // If the user cancels the dialog, exit if (rollContext === null) return log("rollContext", rollContext, hasGrantedDice) rollContext.saveSpell = saveSpell // Update fucking flag let fullModifier = 0 let titleFormula = "" dice = rollContext.changeDice || dice if (hasModifier) { let bonus = Number(options.rollTarget.value) fullModifier = rollContext.modifier === "" ? 0 : parseInt(rollContext.modifier, 10) + bonus fullModifier += (rollContext.saveSpell) ? (options.rollTarget.actorModifiers?.saveModifier ?? 0) : 0 if (Number(rollContext.attackerAim) > 0) { fullModifier += Number(rollContext.attackerAim) } if (fullModifier === 0) { modifierFormula = "0" } else { let modAbs = Math.abs(fullModifier) modifierFormula = `D${modAbs + 1} - 1` } if (hasStaticModifier) { modifierFormula += ` + ${options.rollTarget.staticModifier}` } let sign = fullModifier < 0 ? "-" : "+" if (hasExplode) { titleFormula = `${dice}E ${sign} ${modifierFormula}` } else { titleFormula = `${dice} ${sign} ${modifierFormula}` } } else { modifierFormula = "0" fullModifier = 0 baseFormula = `${dice}` if (hasExplode) { titleFormula = `${dice}E` } else { titleFormula = `${dice}` } } // Latest addition : favor choice at point blank range if (pointBlank) { rollContext.favor = "favor" } if (beyondSkill) { rollContext.favor = "disfavor" } // Specific pain case if (options.rollType === "save" && options.rollTarget.rollKey === "pain" || options.rollTarget.rollKey === "paincourage") { baseFormula = options.rollTarget.rollDice titleFormula = `${dice}` modifierFormula = "0" fullModifier = 0 } // Specific pain/poison/contagion case if (options.rollType === "save" && (options.rollTarget.rollKey === "poison" || options.rollTarget.rollKey === "contagion")) { hasD30 = false hasStaticModifier = true modifierFormula = ` + ${Math.abs(fullModifier)}` titleFormula = `${dice}E + ${Math.abs(fullModifier)}` } if (letItFly) { baseFormula = "1D20" titleFormula = `1D20E` modifierFormula = "0" fullModifier = 0 hasFavor = false hasExplode = true rollContext.favor = "none" } const maxMatch = baseFormula ? baseFormula.match(/\d+$/) : null maxValue = maxMatch ? Number(maxMatch[0]) : 0 const rollData = { type: options.rollType, rollType: options.rollType, target: options.rollTarget, rollName: options.rollName, actorId: options.actorId, actorName: options.actorName, actorImage: options.actorImage, rollMode: rollContext.visibility, hasTarget: options.hasTarget, isDamage: options.isDamage, pointBlank, beyondSkill, letItFly, hasGrantedDice, titleFormula, targetName, ...rollContext, } /** * A hook event that fires before the roll is made. * @function * @memberof hookEvents * @param {Object} options Options for the roll. * @param {Object} rollData All data related to the roll. * @returns {boolean} Explicitly return `false` to prevent roll to be made. */ if (Hooks.call("fvtt-lethal-fantasy.preRoll", options, rollData) === false) return let rollBase = new this(baseFormula, options.data, rollData) const rollModifier = new Roll(modifierFormula, options.data, rollData) await rollModifier.evaluate() await rollBase.evaluate() let rollFavor let badResult if (rollContext.favor === "favor") { rollFavor = new this(baseFormula, options.data, rollData) await rollFavor.evaluate() log("Favor dice", { rollBaseTotal: rollBase.total, rollFavorTotal: rollFavor.total, rollBaseResults: rollBase.dice.map(d => d.results.map(r => r.result)), rollFavorResults: rollFavor.dice.map(d => d.results.map(r => r.result)), baseFormula }) if (game?.dice3d) { game.dice3d.showForRoll(rollFavor, game.user, true) } if (Number(rollFavor.result) > Number(rollBase.result)) { badResult = rollBase.result rollBase = rollFavor } else { badResult = rollFavor.result } rollFavor = null } if (rollContext.favor === "disfavor") { rollFavor = new this(baseFormula, options.data, rollData) await rollFavor.evaluate() log("Disfavor dice", { rollBaseTotal: rollBase.total, rollFavorTotal: rollFavor.total, rollBaseResults: rollBase.dice.map(d => d.results.map(r => r.result)), rollFavorResults: rollFavor.dice.map(d => d.results.map(r => r.result)), baseFormula }) if (game?.dice3d) { game.dice3d.showForRoll(rollFavor, game.user, true) } if (Number(rollFavor.result) < Number(rollBase.result)) { badResult = rollBase.result rollBase = rollFavor } else { badResult = rollFavor.result } rollFavor = null } if (options.forceNoD30) { hasD30 = false } if (hasD30) { let rollD30 = await new Roll("1D30").evaluate() if (game?.dice3d) { game.dice3d.showForRoll(rollD30, game.user, true) } options.D30result = rollD30.total // Compute isRanged for D30: covers defense (isRangedDefense), monster ranged attacks (attackMode), // and PC weapon attacks (isRangedAttack or weaponType) const isRangedForD30 = options.isRangedDefense || options.rollTarget?.attackMode === "ranged" || options.rollTarget?.isRangedAttack === true || options.rollTarget?.weapon?.system?.weaponType === "ranged" const d30Message = D30Roll.getResult( rollD30.total, options.rollType, options.rollTarget?.weapon, { isRanged: isRangedForD30, isSpellSave: saveSpell } ) options.D30message = d30Message } let rollTotal = 0 let diceResults = [] let resultType let diceSum = 0 let singleDice = `1D${maxValue}` for (let i = 0; i < rollBase.dice.length; i++) { const dieResults = rollBase.dice[i].results const resultCount = dieResults.length for (let j = 0; j < resultCount; j++) { let diceResult = dieResults[j].result diceResults.push({ dice: `${singleDice.toUpperCase()}`, value: diceResult }) diceSum += diceResult if (hasMaxValue) { while (diceResult === maxValue) { let r = await new Roll(baseFormula).evaluate() diceResult = r.dice[0].results[0].result diceResults.push({ dice: `${singleDice.toUpperCase()}-1`, value: diceResult - 1 }) diceSum += (diceResult - 1) // Add to DieTerm results so DSN/Foundry display shows explosion dice dieResults.push({ result: diceResult, active: true }) } } } } if (hasGrantedDice && options.rollTarget.grantedDice && options.rollTarget.grantedDice !== "") { titleFormula += ` + ${options.rollTarget.grantedDice.toUpperCase()}` let grantedRoll = new Roll(options.rollTarget.grantedDice) await grantedRoll.evaluate() if (game?.dice3d) { await game.dice3d.showForRoll(grantedRoll, game.user, true) } diceResults.push({ dice: `${options.rollTarget.grantedDice.toUpperCase()}`, value: grantedRoll.total }) rollTotal += grantedRoll.total } if (fullModifier !== 0) { diceResults.push({ dice: `${rollModifier.formula.toUpperCase()}`, value: rollModifier.total }) if (fullModifier < 0) { rollTotal += Math.max(diceSum - rollModifier.total, 0) } else { rollTotal += diceSum + rollModifier.total } } else { rollTotal += diceSum } rollBase.options.resultType = resultType rollBase.options.rollTotal = rollTotal rollBase.options.diceResults = diceResults rollBase.options.rollTarget = options.rollTarget rollBase.options.titleFormula = titleFormula rollBase.options.D30result = options.D30result rollBase.options.D30message = options.D30message rollBase.options.badResult = badResult rollBase.options.rollData = foundry.utils.duplicate(rollData) rollBase.options.defenderId = options.defenderId rollBase.options.defenderTokenId = options.defenderTokenId rollBase.options.extraShieldDr = options.extraShieldDr || 0 rollBase.options.damageTier = options.damageTier || "standard" rollBase.options.d30Bleed = options.d30Bleed || false rollBase.options.d30DamageMultiplier = options.d30DamageMultiplier || 1 rollBase.options.d30DrMultiplier = options.d30DrMultiplier || 1 /** * A hook event that fires after the roll has been made. * @function * @memberof hookEvents * @param {Object} options Options for the roll. * @param {Object} rollData All data related to the roll. * @param {LethalFantasyRoll} roll The resulting roll. * @returns {boolean} Explicitly return `false` to prevent roll to be made. */ if (Hooks.call("fvtt-lethal-fantasy.Roll", options, rollData, rollBase) === false) return return rollBase } finally { // Clear one-shot flag so it doesn't leak to subsequent non-spell saves if (game.lethalFantasy) game.lethalFantasy.spellDefense = false } }