Files
uberwald 3df46b5848 refactor: extract inline HTML to templates, split oversized files, fix bugs
- Extract all inline HTML from JS into 21 Handlebars templates (chat/, dialogs/, ui/)
- Split utils.mjs (1507) into barrel + helpers.mjs, combat.mjs, d30.mjs
- Split roll.mjs (1632) into barrel + roll-base.mjs, roll-prompt.mjs, roll-combat.mjs, roll-damage.mjs
- Split lethal-fantasy.mjs (1426) into bootstrap + chat-reaction.mjs
- Fix: missing async on injectDiceTray (free-roll.mjs:29 SyntaxError)
- Fix: weapon._id fallback for deserialized chat-message weapon objects
- Fix: missing await on rollModifier.evaluate() calls in roll-combat.mjs
- Fix: choices→choicesList ReferenceError in utils.mjs
- Fix: add 12 missing i18n keys (chooseWeapon, chooseSave, attackRoll, etc.)
- Fix: restore sideLabel in bonus-die-select.hbs
- Clean: remove dead messageContent param, console.log→log()
- Style: barrel files preserve existing import paths
2026-06-28 19:13:05 +02:00

599 lines
22 KiB
JavaScript

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<Object|null>} 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
}
}