539841c4ff
Explosion rolls were evaluated as separate Roll instances but never
added to the original DieTerm's results array. Dice So Nice reads
DieTerm.results to render 3D dice, so explosions were invisible.
Now each explosion result is pushed into the DieTerm's results array
({result, active:true}), letting DSN render explosion dice in the
correct chronological order alongside the main die.
Applies to prompt(), promptRangedDefense(), promptRangedAttack(),
and rollSpellDamageToMessage().
1617 lines
62 KiB
JavaScript
1617 lines
62 KiB
JavaScript
import { SYSTEM } from "../config/system.mjs"
|
|
import LethalFantasyUtils from "../utils.mjs"
|
|
import D30Roll from "./d30-roll.mjs"
|
|
|
|
export default class LethalFantasyRoll extends Roll {
|
|
/**
|
|
* The HTML template path used to render dice checks of this type
|
|
* @type {string}
|
|
*/
|
|
static CHAT_TEMPLATE = "systems/fvtt-lethal-fantasy/templates/chat-message.hbs"
|
|
|
|
get type() {
|
|
return this.options.type
|
|
}
|
|
|
|
get titleFormula() {
|
|
return this.options.titleFormula
|
|
}
|
|
|
|
get rollName() {
|
|
return this.options.rollName
|
|
}
|
|
|
|
get target() {
|
|
return this.options.target
|
|
}
|
|
|
|
get value() {
|
|
return this.options.value
|
|
}
|
|
|
|
get treshold() {
|
|
return this.options.treshold
|
|
}
|
|
|
|
get actorId() {
|
|
return this.options.actorId
|
|
}
|
|
|
|
get actorName() {
|
|
return this.options.actorName
|
|
}
|
|
|
|
get actorImage() {
|
|
return this.options.actorImage
|
|
}
|
|
|
|
get modifier() {
|
|
return this.options.modifier
|
|
}
|
|
|
|
get resultType() {
|
|
return this.options.resultType
|
|
}
|
|
|
|
get isFailure() {
|
|
return this.resultType === "failure"
|
|
}
|
|
|
|
get hasTarget() {
|
|
return this.options.hasTarget
|
|
}
|
|
|
|
get targetName() {
|
|
return this.options.targetName
|
|
}
|
|
|
|
get targetArmor() {
|
|
return this.options.targetArmor
|
|
}
|
|
|
|
get targetMalus() {
|
|
return this.options.targetMalus
|
|
}
|
|
|
|
get realDamage() {
|
|
return this.options.realDamage
|
|
}
|
|
|
|
get rollTotal() {
|
|
return this.options.rollTotal
|
|
}
|
|
|
|
get diceResults() {
|
|
return this.options.diceResults
|
|
}
|
|
|
|
get rollTarget() {
|
|
return this.options.rollTarget
|
|
}
|
|
|
|
get D30result() {
|
|
return this.options.D30result
|
|
}
|
|
|
|
get D30message() {
|
|
return this.options.D30message
|
|
}
|
|
|
|
get badResult() {
|
|
return this.options.badResult
|
|
}
|
|
|
|
get rollData() {
|
|
return this.options.rollData
|
|
}
|
|
|
|
get defenderId() {
|
|
return this.options.defenderId
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
static async 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("Rolling with favor", rollFavor)
|
|
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()
|
|
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++) {
|
|
for (let j = 0; j < rollBase.dice[i].results.length; j++) {
|
|
let diceResult = rollBase.dice[i].results[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
|
|
rollBase.dice[i].results.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
|
|
}
|
|
}
|
|
|
|
/* ***********************************************************/
|
|
static async promptInitiative(options = {}) {
|
|
const rollModes = foundry.utils.duplicate(CONFIG.ChatMessage.modes); // v12 : Object.fromEntries(Object.entries(CONFIG.Dice.rollModes).map(([key, value]) => [key, game.i18n.localize(value)]))
|
|
const fieldRollMode = new foundry.data.fields.StringField({
|
|
choices: rollModes,
|
|
blank: false,
|
|
default: "public",
|
|
})
|
|
|
|
if (SYSTEM.INITIATIVE_DICE_CHOICES_PER_CLASS[options.actorClass]) {
|
|
options.initiativeDiceChoice = SYSTEM.INITIATIVE_DICE_CHOICES_PER_CLASS[options.actorClass]
|
|
} else {
|
|
options.initiativeDiceChoice = SYSTEM.INITIATIVE_DICE_CHOICES_PER_CLASS["untrained"]
|
|
}
|
|
|
|
let dialogContext = {
|
|
actorClass: options.actorClass,
|
|
initiativeDiceChoice: options.initiativeDiceChoice,
|
|
initiativeDice: "1D20",
|
|
maxInit: options.maxInit,
|
|
fieldRollMode,
|
|
rollModes
|
|
}
|
|
|
|
const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/roll-initiative-dialog.hbs", dialogContext)
|
|
|
|
const label = game.i18n.localize("LETHALFANTASY.Label.initiative")
|
|
const rollContext = await foundry.applications.api.DialogV2.wait({
|
|
window: { title: "Initiative Roll" },
|
|
classes: ["lethalfantasy"],
|
|
content,
|
|
buttons: [
|
|
{
|
|
action: "initiative",
|
|
type: "button",
|
|
label: label,
|
|
callback: (event, button) => {
|
|
const output = Array.from(button.form.elements).reduce((obj, input) => {
|
|
if (input.name) obj[input.name] = input.value
|
|
return obj
|
|
}, {})
|
|
return output
|
|
},
|
|
},
|
|
],
|
|
rejectClose: false // Click on Close button will not launch an error
|
|
})
|
|
|
|
if (!rollContext) return
|
|
|
|
// When the value is a plain number (e.g. "1" for Declared Ready on Alert), wrapping it in
|
|
// min(1, maxInit) produces a dice-less formula that FoundryVTT cannot evaluate to a valid
|
|
// total. Use the constant directly; min() is only needed for actual dice expressions.
|
|
const isDiceFormula = /[dD]/.test(rollContext.initiativeDice)
|
|
const formula = isDiceFormula ? `min(${rollContext.initiativeDice}, ${options.maxInit})` : rollContext.initiativeDice
|
|
|
|
let initRoll = new Roll(formula, options.data)
|
|
await initRoll.evaluate()
|
|
let msg = await initRoll.toMessage({ flavor: `Initiative for ${options.actorName}` }, { messageMode: rollContext.visibility })
|
|
if (game?.dice3d && initRoll.dice?.length) {
|
|
await game.dice3d.waitFor3DAnimationByMessageID(msg.id)
|
|
}
|
|
|
|
if (options.combatId && options.combatantId) {
|
|
let combat = game.combats.get(options.combatId)
|
|
await combat.updateEmbeddedDocuments("Combatant", [{ _id: options.combatantId, initiative: initRoll.total, 'system.progressionCount': 0, [`flags.${SYSTEM.id}.firstActionTaken`]: false }])
|
|
}
|
|
}
|
|
|
|
/* ***********************************************************/
|
|
static async promptCombatAction(options = {}) {
|
|
|
|
const rollModes = foundry.utils.duplicate(CONFIG.ChatMessage.modes); // v12 : Object.fromEntries(Object.entries(CONFIG.Dice.rollModes).map(([key, value]) => [key, game.i18n.localize(value)]))
|
|
const fieldRollMode = new foundry.data.fields.StringField({
|
|
choices: rollModes,
|
|
blank: false,
|
|
default: "public",
|
|
})
|
|
|
|
let combatant = game.combats.get(options.combatId)?.combatants?.get(options.combatantId)
|
|
if (!combatant) {
|
|
console.error("No combatant found for this combat")
|
|
return
|
|
}
|
|
let currentAction = combatant.getFlag(SYSTEM.id, "currentAction")
|
|
|
|
let position = game.user.getFlag(SYSTEM.id, "combat-action-dialog-pos") || { top: -1, left: -1 }
|
|
|
|
let dialogContext = {
|
|
progressionDiceId: "",
|
|
fieldRollMode,
|
|
rollModes,
|
|
currentAction,
|
|
...options
|
|
}
|
|
|
|
const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/combat-action-dialog.hbs", dialogContext)
|
|
|
|
let buttons = []
|
|
if (currentAction) {
|
|
if (currentAction.type === "weapon") {
|
|
let weaponLabel = "Roll progression dice"
|
|
if (currentAction.rangedMode) {
|
|
// Compute loading count from the speed formula (e.g. "3+1d6" → load=3)
|
|
const speedStr = currentAction.system?.speed?.[currentAction.rangedMode] ?? ""
|
|
const rangedLoad = currentAction.rangedLoad ?? (Number(speedStr.split("+")[0]) || 0)
|
|
if (rangedLoad > 0 && !currentAction.weaponLoaded) {
|
|
weaponLabel = "Load weapon"
|
|
}
|
|
}
|
|
buttons.push({
|
|
action: "roll",
|
|
type: "button",
|
|
label: weaponLabel,
|
|
callback: (event, button) => {
|
|
let pos = $('#combat-action-dialog').position()
|
|
game.user.setFlag(SYSTEM.id, "combat-action-dialog-pos", pos)
|
|
return "rollProgressionDice"
|
|
},
|
|
})
|
|
} else if (currentAction.type === "spell" || currentAction.type === "miracle") {
|
|
let label = ""
|
|
if (currentAction.spellStatus === "castingTime") {
|
|
let pos = $('#combat-action-dialog').position()
|
|
game.user.setFlag(SYSTEM.id, "combat-action-dialog-pos", pos)
|
|
label = "Wait casting time"
|
|
}
|
|
if (currentAction.spellStatus === "toBeCasted") {
|
|
let pos = $('#combat-action-dialog').position()
|
|
game.user.setFlag(SYSTEM.id, "combat-action-dialog-pos", pos)
|
|
label = "Cast spell/miracle"
|
|
}
|
|
if (currentAction.spellStatus === "lethargy") {
|
|
let pos = $('#combat-action-dialog').position()
|
|
game.user.setFlag(SYSTEM.id, "combat-action-dialog-pos", pos)
|
|
label = "Roll lethargy dice"
|
|
}
|
|
buttons.push({
|
|
action: "roll",
|
|
type: "button",
|
|
label: label,
|
|
callback: (event, button) => {
|
|
let pos = $('#combat-action-dialog').position()
|
|
game.user.setFlag(SYSTEM.id, "combat-action-dialog-pos", foundry.utils.duplicate(pos))
|
|
return "rollLethargyDice"
|
|
},
|
|
})
|
|
}
|
|
} else {
|
|
buttons.push({
|
|
action: "roll",
|
|
type: "button",
|
|
label: "Select action",
|
|
callback: (event, button) => {
|
|
let pos = $('#combat-action-dialog').position()
|
|
game.user.setFlag(SYSTEM.id, "combat-action-dialog-pos", foundry.utils.duplicate(pos))
|
|
const output = Array.from(button.form.elements).reduce((obj, input) => {
|
|
if (input.name) obj[input.name] = input.value
|
|
return obj
|
|
}, {})
|
|
return output
|
|
},
|
|
},
|
|
)
|
|
}
|
|
buttons.push({
|
|
action: "cancel",
|
|
type: "button",
|
|
label: "Other action, not listed here",
|
|
callback: (event, button) => {
|
|
let pos = $('#combat-action-dialog').position()
|
|
game.user.setFlag(SYSTEM.id, "combat-action-dialog-pos", foundry.utils.duplicate(pos))
|
|
return null;
|
|
}
|
|
})
|
|
|
|
let rollContext = await foundry.applications.api.DialogV2.wait({
|
|
window: { title: "Combat Action Dialog" },
|
|
id: "combat-action-dialog",
|
|
classes: ["lethalfantasy"],
|
|
position,
|
|
content,
|
|
buttons,
|
|
rejectClose: false // Click on Close button will not launch an error
|
|
})
|
|
|
|
log("RollContext", dialogContext, rollContext)
|
|
// If action is cancelled, exit
|
|
if (rollContext === null || rollContext === "cancel") {
|
|
await combatant.setFlag(SYSTEM.id, "currentAction", "")
|
|
let message = `${combatant.name} : Other action, progression reset`
|
|
await ChatMessage.create({ content: message, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
|
|
return
|
|
}
|
|
|
|
// Setup the current action
|
|
if (!currentAction || currentAction === "") {
|
|
// Get the item from the returned selectedChoice value
|
|
let selectedChoice = rollContext.selectedChoice
|
|
let rangedMode
|
|
if (selectedChoice.match("simpleAim")) {
|
|
selectedChoice = selectedChoice.replace("simpleAim", "")
|
|
rangedMode = "simpleAim"
|
|
}
|
|
if (selectedChoice.match("carefulAim")) {
|
|
selectedChoice = selectedChoice.replace("carefulAim", "")
|
|
rangedMode = "carefulAim"
|
|
}
|
|
if (selectedChoice.match("focusedAim")) {
|
|
selectedChoice = selectedChoice.replace("focusedAim", "")
|
|
rangedMode = "focusedAim"
|
|
}
|
|
let selectedItem = combatant.actor.items.find(i => i.id === selectedChoice)
|
|
// Setup flag for combat action usage
|
|
let actionItem = foundry.utils.duplicate(selectedItem)
|
|
// First action of this combat: use the class-based starting threshold;
|
|
// all subsequent actions reset to 1 (normal progression).
|
|
const firstActionTaken = combatant.getFlag(SYSTEM.id, "firstActionTaken")
|
|
actionItem.progressionCount = firstActionTaken ? 1 : (combatant.actor.system.combat?.combatProgressionStart ?? 1)
|
|
if (!firstActionTaken) await combatant.setFlag(SYSTEM.id, "firstActionTaken", true)
|
|
actionItem.rangedMode = rangedMode
|
|
// If this is a spell/miracle with multiple damage tiers, prompt tier choice
|
|
if (actionItem.system?.damageDice) {
|
|
const tiers = [
|
|
{ id: "standard", label: "Standard", dice: actionItem.system.damageDice },
|
|
{ id: "overpowered", label: "Overpowered", dice: actionItem.system.damageDiceOverpowered },
|
|
{ id: "overpowered2", label: "Overpowered 2", dice: actionItem.system.damageDiceOverpowered2 },
|
|
].filter(t => t.dice)
|
|
if (tiers.length > 1) {
|
|
const tierChoice = await foundry.applications.api.DialogV2.wait({
|
|
window: { title: "Choose Damage Tier" },
|
|
classes: ["lethalfantasy"],
|
|
content: `<div class="grit-luck-dialog"><p><strong>${selectedItem.name}</strong> has multiple damage tiers.</p><p>Choose which damage to use when the attack lands:</p></div>`,
|
|
buttons: tiers.map(t => ({
|
|
action: t.id,
|
|
type: "button",
|
|
label: `${t.label} (${t.dice.toUpperCase()})`,
|
|
icon: "fa-solid fa-wand-magic-sparkles",
|
|
callback: () => t.id
|
|
})),
|
|
rejectClose: false
|
|
})
|
|
actionItem.damageTier = tierChoice || "standard"
|
|
}
|
|
}
|
|
actionItem.castingTime = 1
|
|
actionItem.spellStatus = "castingTime"
|
|
// Set the flag on the combatant
|
|
await combatant.setFlag(SYSTEM.id, "currentAction", actionItem)
|
|
let message = `${combatant.name} action : ${selectedItem.name}, start rolling progression dice or casting time`
|
|
await ChatMessage.create({ content: message, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
|
|
rollContext = (actionItem.type === "weapon") ? "rollProgressionDice" : "rollLethargyDice" // Set the roll context to rollProgressionDice
|
|
currentAction = actionItem
|
|
}
|
|
|
|
if (currentAction) {
|
|
if (rollContext === "rollLethargyDice") {
|
|
if (currentAction.spellStatus === "castingTime") {
|
|
let time = currentAction.type === "spell" ? currentAction.system.castingTime : currentAction.system.prayerTime
|
|
if (currentAction.castingTime < time) {
|
|
let message = `Casting time : ${currentAction.name}, count : ${currentAction.castingTime}/${time}`
|
|
await ChatMessage.create({ content: message, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
|
|
currentAction.castingTime += 1
|
|
await combatant.setFlag(SYSTEM.id, "currentAction", foundry.utils.duplicate(currentAction))
|
|
return
|
|
} else {
|
|
// Last counting second — announce ready and transition immediately (no extra second consumed)
|
|
let message = `Casting time : ${currentAction.name}, count : ${time}/${time} — ready to cast next second !`
|
|
await ChatMessage.create({ content: message, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
|
|
currentAction.castingTime = 1
|
|
currentAction.spellStatus = "toBeCasted"
|
|
await combatant.setFlag(SYSTEM.id, "currentAction", foundry.utils.duplicate(currentAction))
|
|
return
|
|
}
|
|
}
|
|
if (currentAction.spellStatus === "toBeCasted") {
|
|
combatant.actor.prepareRoll((currentAction.type === "spell") ? "spell-attack" : "miracle-attack", currentAction._id)
|
|
if (currentAction.type === "spell") {
|
|
currentAction.spellStatus = "lethargy"
|
|
await combatant.setFlag(SYSTEM.id, "currentAction", foundry.utils.duplicate(currentAction))
|
|
} else {
|
|
// No lethargy for miracle
|
|
await combatant.setFlag(SYSTEM.id, "currentAction", "")
|
|
}
|
|
return
|
|
}
|
|
if (currentAction.spellStatus === "lethargy") {
|
|
// Roll lethargy dice
|
|
let dice = LethalFantasyUtils.getLethargyDice(currentAction.system.level)
|
|
let roll = new Roll(dice)
|
|
await roll.evaluate()
|
|
if (game?.dice3d) {
|
|
await game.dice3d.showForRoll(roll)
|
|
}
|
|
let max = roll.dice[0].faces - 1
|
|
let toCompare = Math.min(currentAction.progressionCount, max)
|
|
if (roll.total <= toCompare) {
|
|
// Notify that the player can act now with a chat message
|
|
const messageContent = await foundry.applications.handlebars.renderTemplate(
|
|
"systems/fvtt-lethal-fantasy/templates/progression-message.hbs",
|
|
{
|
|
success: true,
|
|
actorName: combatant.actor.name,
|
|
weaponName: currentAction.name,
|
|
rollResult: roll.total,
|
|
isLethargy: true
|
|
}
|
|
)
|
|
await ChatMessage.create({ content: messageContent, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
|
|
// Update the combatant progression count
|
|
await combatant.setFlag(SYSTEM.id, "currentAction", "")
|
|
// Display the action selection window again
|
|
combatant.actor.system.rollProgressionDice(options.combatId, options.combatantId)
|
|
} else {
|
|
// Notify that the player cannot act now with a chat message
|
|
currentAction.progressionCount += 1
|
|
await combatant.setFlag(SYSTEM.id, "currentAction", foundry.utils.duplicate(currentAction))
|
|
const messageContent = await foundry.applications.handlebars.renderTemplate(
|
|
"systems/fvtt-lethal-fantasy/templates/progression-message.hbs",
|
|
{
|
|
success: false,
|
|
actorName: combatant.actor.name,
|
|
weaponName: currentAction.name,
|
|
rollResult: roll.total,
|
|
progressionCount: currentAction.progressionCount,
|
|
isLethargy: true
|
|
}
|
|
)
|
|
await ChatMessage.create({ content: messageContent, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
|
|
}
|
|
}
|
|
}
|
|
|
|
if (rollContext === "rollProgressionDice") {
|
|
let formula = currentAction.system.combatProgressionDice
|
|
if (currentAction?.rangedMode) {
|
|
let toSplit = currentAction.system.speed[currentAction.rangedMode]
|
|
let split = toSplit.split("+")
|
|
currentAction.rangedLoad = Number(split[0]) || 0
|
|
formula = split[1]
|
|
log("Ranged Mode", currentAction.rangedMode, currentAction.rangedLoad, formula)
|
|
}
|
|
// Range weapon loading
|
|
if (!currentAction.weaponLoaded && currentAction.rangedLoad) {
|
|
if (currentAction.progressionCount < currentAction.rangedLoad) {
|
|
let message = `Ranged weapon ${currentAction.name} is loading, loading count : ${currentAction.progressionCount}/${currentAction.rangedLoad}`
|
|
await ChatMessage.create({ content: message, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
|
|
currentAction.progressionCount += 1
|
|
await combatant.setFlag(SYSTEM.id, "currentAction", foundry.utils.duplicate(currentAction))
|
|
} else {
|
|
let message = `Ranged weapon ${currentAction.name} is loaded !`
|
|
await ChatMessage.create({ content: message, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
|
|
currentAction.weaponLoaded = true
|
|
currentAction.progressionCount = 1
|
|
await combatant.setFlag(SYSTEM.id, "currentAction", foundry.utils.duplicate(currentAction))
|
|
}
|
|
return
|
|
}
|
|
|
|
// Melee mode
|
|
let isMonster = combatant.actor.type === "monster"
|
|
// Get the dice and roll it if
|
|
let roll = new Roll(formula)
|
|
await roll.evaluate()
|
|
|
|
let max = roll.dice[0].faces - 1
|
|
max = Math.min(currentAction.progressionCount, max)
|
|
let msg = await roll.toMessage({ flavor: `Progression Roll for ${currentAction.name}, progression count : ${currentAction.progressionCount}/${max}` }, { messageMode: rollContext.visibility })
|
|
if (game?.dice3d) {
|
|
await game.dice3d.waitFor3DAnimationByMessageID(msg.id)
|
|
}
|
|
|
|
if (roll.total <= max) {
|
|
// Notify that the player can act now with a chat message
|
|
const messageContent = await foundry.applications.handlebars.renderTemplate(
|
|
"systems/fvtt-lethal-fantasy/templates/progression-message.hbs",
|
|
{
|
|
success: true,
|
|
actorName: combatant.actor.name,
|
|
weaponName: currentAction.name,
|
|
rollResult: roll.total
|
|
}
|
|
)
|
|
await ChatMessage.create({ content: messageContent, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
|
|
await combatant.setFlag(SYSTEM.id, "currentAction", "")
|
|
combatant.actor.prepareRoll(currentAction.type === "weapon" ? "weapon-attack" : "spell-attack", currentAction._id)
|
|
} else {
|
|
// Notify that the player cannot act now with a chat message
|
|
currentAction.progressionCount += 1
|
|
await combatant.setFlag(SYSTEM.id, "currentAction", foundry.utils.duplicate(currentAction))
|
|
const messageContent = await foundry.applications.handlebars.renderTemplate(
|
|
"systems/fvtt-lethal-fantasy/templates/progression-message.hbs",
|
|
{
|
|
success: false,
|
|
actorName: combatant.actor.name,
|
|
weaponName: currentAction.name,
|
|
rollResult: roll.total,
|
|
progressionCount: currentAction.progressionCount
|
|
}
|
|
)
|
|
await ChatMessage.create({ content: messageContent, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ***********************************************************/
|
|
static async promptRangedDefense(options = {}) {
|
|
|
|
const rollModes = foundry.utils.duplicate(CONFIG.ChatMessage.modes);
|
|
const fieldRollMode = new foundry.data.fields.StringField({
|
|
choices: rollModes,
|
|
blank: false,
|
|
default: "public",
|
|
})
|
|
|
|
let dialogContext = {
|
|
movementChoices: SYSTEM.MOVEMENT_CHOICES,
|
|
moveDirectionChoices: SYSTEM.MOVE_DIRECTION_CHOICES,
|
|
sizeChoices: SYSTEM.SIZE_CHOICES,
|
|
rangeChoices: SYSTEM.RANGE_CHOICES,
|
|
attackerAimChoices: SYSTEM.ATTACKER_AIM_CHOICES,
|
|
movement: "none",
|
|
moveDirection: "none",
|
|
size: "+5",
|
|
range: "short",
|
|
attackerAim: "simple",
|
|
fieldRollMode,
|
|
rollModes
|
|
}
|
|
|
|
const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/range-defense-dialog.hbs", dialogContext)
|
|
|
|
const label = game.i18n.localize("LETHALFANTASY.Label.rangeDefenseRoll")
|
|
const rollContext = await foundry.applications.api.DialogV2.wait({
|
|
window: { title: "Range Defense" },
|
|
classes: ["lethalfantasy"],
|
|
content,
|
|
buttons: [
|
|
{
|
|
action: "rangeDefense",
|
|
type: "button",
|
|
label: label,
|
|
callback: (event, button) => {
|
|
const output = Array.from(button.form.elements).reduce((obj, input) => {
|
|
if (input.name) obj[input.name] = input.value
|
|
return obj
|
|
}, {})
|
|
return output
|
|
},
|
|
},
|
|
],
|
|
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)
|
|
// Add disfavor/favor option if point blank range
|
|
if (rollContext.range === "pointblank") {
|
|
rollContext.movement = rollContext.movement.replace("kh", "")
|
|
rollContext.movement = rollContext.movement.replace("kl", "")
|
|
rollContext.movement += "kl" // Add the kl to the movement (disfavor for point blank range)
|
|
rollContext.range = "0"
|
|
}
|
|
if (rollContext.range === "beyondskill") {
|
|
rollContext.movement = rollContext.movement.replace("kh", "")
|
|
rollContext.movement = rollContext.movement.replace("kl", "")
|
|
rollContext.movement += "kh" // Add the kl to the movement (favor for point blank range)
|
|
rollContext.range = "+11"
|
|
}
|
|
|
|
// Build the final modifier
|
|
let fullModifier = Number(rollContext.moveDirection) +
|
|
Number(rollContext.size) +
|
|
Number(rollContext.range) +
|
|
Number(rollContext?.attackerAim || 0)
|
|
|
|
let modifierFormula
|
|
if (fullModifier === 0) {
|
|
modifierFormula = "0"
|
|
} else {
|
|
let modAbs = Math.abs(fullModifier)
|
|
modifierFormula = `D${modAbs + 1} -1`
|
|
}
|
|
|
|
let rollData = { ...rollContext }
|
|
// Merge rollContext object into options object
|
|
options = { ...options, ...rollContext }
|
|
options.rollName = "Ranged Defense"
|
|
options.rollType = "weapon-defense"
|
|
options.type = options.rollType // Required: this.type reads options.type
|
|
options.rollMode = rollContext.visibility // Required: callers pass roll.options.rollMode to toMessage
|
|
|
|
const rollBase = new this(rollContext.movement, options.data, rollData)
|
|
const rollModifier = new Roll(modifierFormula, options.data, rollData)
|
|
rollModifier.evaluate()
|
|
await rollBase.evaluate()
|
|
let rollD30 = await new Roll("1D30").evaluate()
|
|
options.D30result = rollD30.total
|
|
options.D30message = D30Roll.getResult(rollD30.total, options.rollType, options.rollTarget?.weapon, { isRanged: true })
|
|
|
|
let badResult = 0
|
|
if (rollContext.movement.includes("kh")) {
|
|
rollData.favor = "favor"
|
|
badResult = Math.min(rollBase.terms[0].results[0].result, rollBase.terms[0].results[1]?.result || 20)
|
|
}
|
|
if (rollContext.movement.includes("kl")) {
|
|
rollData.favor = "disfavor"
|
|
badResult = Math.max(rollBase.terms[0].results[0].result, rollBase.terms[0].results[1]?.result || 1)
|
|
}
|
|
let dice = rollContext.movement
|
|
let maxValue = 20 // As per latest changes (was : Number(dice.match(/\d+$/)[0])
|
|
let rollTotal = -1
|
|
let diceResults = []
|
|
let resultType
|
|
|
|
let diceResult = rollBase.dice[0].results[0].result
|
|
diceResults.push({ dice: `${dice.toUpperCase()}`, value: diceResult })
|
|
let diceSum = diceResult
|
|
while (diceResult === maxValue) {
|
|
let r = await new Roll(dice).evaluate()
|
|
diceResult = r.dice[0].results[0].result
|
|
diceResults.push({ dice: `${dice.toUpperCase()}-1`, value: diceResult - 1 })
|
|
diceSum += (diceResult - 1)
|
|
rollBase.dice[0].results.push({ result: diceResult, active: true })
|
|
}
|
|
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 = { ...rollBase.options, ...options }
|
|
rollBase.options.resultType = resultType
|
|
rollBase.options.rollTotal = rollTotal
|
|
rollBase.options.diceResults = diceResults
|
|
rollBase.options.rollTarget = options.rollTarget
|
|
rollBase.options.titleFormula = `1D20E + ${modifierFormula}`
|
|
rollBase.options.D30result = options.D30result
|
|
rollBase.options.D30message = options.D30message
|
|
rollBase.options.rollName = "Ranged Defense"
|
|
rollBase.options.badResult = badResult
|
|
rollBase.options.rollData = foundry.utils.duplicate(rollData)
|
|
/**
|
|
* 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.
|
|
*/
|
|
|
|
return rollBase
|
|
}
|
|
|
|
/**
|
|
* Prompts the GM for ranged attack context (movement, range, target size, aim) when a monster
|
|
* attacks with a ranged weapon, then evaluates an exploding D20 attack roll with the resulting modifiers.
|
|
*
|
|
* @param {Object} options Options for the roll.
|
|
* @param {string} options.actorId The attacker actor ID.
|
|
* @param {string} options.actorName The attacker actor name.
|
|
* @param {Object} options.rollTarget The rollTarget containing attackModifier and related data.
|
|
* @returns {Promise<LethalFantasyRoll|null>} The resulting roll, or null if cancelled.
|
|
*/
|
|
static async promptRangedAttack(options = {}) {
|
|
const rollModes = foundry.utils.duplicate(CONFIG.ChatMessage.modes)
|
|
const fieldRollMode = new foundry.data.fields.StringField({
|
|
choices: rollModes,
|
|
blank: false,
|
|
default: "public",
|
|
})
|
|
|
|
let dialogContext = {
|
|
attackerMovementChoices: SYSTEM.ATTACKER_MOVEMENT_CHOICES,
|
|
rangeChoices: SYSTEM.RANGE_CHOICES,
|
|
sizeChoices: SYSTEM.SIZE_CHOICES,
|
|
attackerAimChoices: SYSTEM.ATTACKER_AIM_CHOICES,
|
|
movement: "none",
|
|
range: "short",
|
|
size: "+5",
|
|
attackerAim: "simple",
|
|
fieldRollMode,
|
|
rollModes
|
|
}
|
|
|
|
const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/range-attack-dialog.hbs", dialogContext)
|
|
|
|
const label = game.i18n.localize("LETHALFANTASY.Label.rangeAttackRoll")
|
|
const rollContext = await foundry.applications.api.DialogV2.wait({
|
|
window: { title: "Ranged Attack" },
|
|
classes: ["lethalfantasy"],
|
|
content,
|
|
buttons: [
|
|
{
|
|
action: "rangedAttack",
|
|
type: "button",
|
|
label,
|
|
callback: (event, button) => {
|
|
const output = Array.from(button.form.elements).reduce((obj, input) => {
|
|
if (input.name) obj[input.name] = input.value
|
|
return obj
|
|
}, {})
|
|
return output
|
|
},
|
|
},
|
|
],
|
|
rejectClose: false
|
|
})
|
|
|
|
if (rollContext === null) return null
|
|
|
|
// Handle pointblank: attacker at point blank gets favor (standing still easier to aim)
|
|
if (rollContext.range === "pointblank") {
|
|
rollContext.movement = rollContext.movement.replace("kh", "")
|
|
rollContext.movement = rollContext.movement.replace("kl", "")
|
|
rollContext.movement += "kh" // Favor for attacker at point blank
|
|
rollContext.range = "0"
|
|
}
|
|
// Handle beyondskill: extreme range gives disfavor to attacker
|
|
if (rollContext.range === "beyondskill") {
|
|
rollContext.movement = rollContext.movement.replace("kh", "")
|
|
rollContext.movement = rollContext.movement.replace("kl", "")
|
|
rollContext.movement += "kl" // Disfavor for attacker beyond skill range
|
|
rollContext.range = "+11"
|
|
}
|
|
|
|
// Compute contextual penalty: range + target_size, reduced by aim bonus and attack modifier
|
|
const attackModifier = options.rollTarget?.attackModifier ?? 0
|
|
const contextualPenalty = Number(rollContext.range) + Number(rollContext.size)
|
|
const aimBonus = Number(rollContext.attackerAim || 0)
|
|
const fullModifier = contextualPenalty - aimBonus - attackModifier
|
|
|
|
let modifierFormula
|
|
if (fullModifier === 0) {
|
|
modifierFormula = "0"
|
|
} else {
|
|
const modAbs = Math.abs(fullModifier)
|
|
modifierFormula = `D${modAbs + 1} -1`
|
|
}
|
|
|
|
const rollData = { ...rollContext }
|
|
options = { ...options, ...rollContext }
|
|
options.rollName = "Ranged Attack"
|
|
options.rollType = options.rollType || "monster-attack"
|
|
options.type = options.rollType // Required: this.type reads options.type, used to build weaponDamageOptions in toHTML
|
|
options.rollMode = rollContext.visibility // Required: callers pass roll.options.rollMode to toMessage
|
|
options.isRangedAttack = true
|
|
|
|
const rollBase = new this(rollContext.movement, options.data, rollData)
|
|
const rollModifier = new Roll(modifierFormula, options.data, rollData)
|
|
rollModifier.evaluate()
|
|
await rollBase.evaluate()
|
|
const rollD30 = await new Roll("1D30").evaluate()
|
|
options.D30result = rollD30.total
|
|
options.D30message = D30Roll.getResult(rollD30.total, options.rollType, undefined, { isRanged: true })
|
|
|
|
// Determine favor from dice formula
|
|
let badResult = 0
|
|
if (rollContext.movement.includes("kh")) {
|
|
rollData.favor = "favor"
|
|
badResult = Math.min(rollBase.terms[0].results[0].result, rollBase.terms[0].results[1]?.result || 20)
|
|
}
|
|
if (rollContext.movement.includes("kl")) {
|
|
rollData.favor = "disfavor"
|
|
badResult = Math.max(rollBase.terms[0].results[0].result, rollBase.terms[0].results[1]?.result || 1)
|
|
}
|
|
|
|
const dice = rollContext.movement
|
|
const maxValue = 20
|
|
let rollTotal = -1
|
|
let diceResults = []
|
|
|
|
let diceResult = rollBase.dice[0].results[0].result
|
|
diceResults.push({ dice: `${dice.toUpperCase()}`, value: diceResult })
|
|
let diceSum = diceResult
|
|
// Exploding dice
|
|
while (diceResult === maxValue) {
|
|
const r = await new Roll(dice).evaluate()
|
|
diceResult = r.dice[0].results[0].result
|
|
diceResults.push({ dice: `${dice.toUpperCase()}-1`, value: diceResult - 1 })
|
|
diceSum += (diceResult - 1)
|
|
rollBase.dice[0].results.push({ result: diceResult, active: true })
|
|
}
|
|
|
|
if (fullModifier !== 0) {
|
|
diceResults.push({ dice: `${rollModifier.formula.toUpperCase()}`, value: rollModifier.total })
|
|
if (fullModifier > 0) {
|
|
// Net penalty: subtract from roll
|
|
rollTotal = Math.max(diceSum - rollModifier.total, 0)
|
|
} else {
|
|
// Net bonus: add to roll
|
|
rollTotal = diceSum + rollModifier.total
|
|
}
|
|
} else {
|
|
rollTotal = diceSum
|
|
}
|
|
|
|
rollBase.options = { ...rollBase.options, ...options }
|
|
rollBase.options.resultType = undefined
|
|
rollBase.options.rollTotal = rollTotal
|
|
rollBase.options.diceResults = diceResults
|
|
rollBase.options.rollTarget = options.rollTarget
|
|
rollBase.options.titleFormula = `1D20E + ${modifierFormula}`
|
|
rollBase.options.D30result = options.D30result
|
|
rollBase.options.D30message = options.D30message
|
|
rollBase.options.rollName = "Ranged Attack"
|
|
rollBase.options.badResult = badResult
|
|
rollBase.options.rollData = foundry.utils.duplicate(rollData)
|
|
|
|
return rollBase
|
|
}
|
|
|
|
/**
|
|
* Creates a title based on the given type.
|
|
*
|
|
* @param {string} type The type of the roll.
|
|
* @param {string} target The target of the roll.
|
|
* @returns {string} The generated title.
|
|
*/
|
|
static createTitle(type, target) {
|
|
switch (type) {
|
|
case "challenge":
|
|
return `${game.i18n.localize("LETHALFANTASY.Label.titleChallenge")}`
|
|
case "save":
|
|
return `${game.i18n.localize("LETHALFANTASY.Label.titleSave")}`
|
|
case "monster-skill":
|
|
case "skill":
|
|
return `${game.i18n.localize("LETHALFANTASY.Label.titleSkill")}`
|
|
case "weapon-attack":
|
|
return `${game.i18n.localize("LETHALFANTASY.Label.weapon-attack")}`
|
|
case "weapon-defense":
|
|
return `${game.i18n.localize("LETHALFANTASY.Label.weapon-defense")}`
|
|
case "weapon-damage":
|
|
return `${game.i18n.localize("LETHALFANTASY.Label.weapon-damage")}`
|
|
case "spell":
|
|
case "spell-attack":
|
|
case "spell-power":
|
|
return `${game.i18n.localize("LETHALFANTASY.Label.spell")}`
|
|
case "miracle":
|
|
case "miracle-attack":
|
|
case "miracle-power":
|
|
return `${game.i18n.localize("LETHALFANTASY.Label.miracle")}`
|
|
default:
|
|
return game.i18n.localize("LETHALFANTASY.Label.titleStandard")
|
|
}
|
|
}
|
|
|
|
/** @override */
|
|
async render(chatOptions = {}) {
|
|
let chatData = await this._getChatCardData(chatOptions.isPrivate)
|
|
log("ChatData", chatData)
|
|
return await foundry.applications.handlebars.renderTemplate(this.constructor.CHAT_TEMPLATE, chatData)
|
|
}
|
|
|
|
/*
|
|
* Generates the data required for rendering a roll chat card.
|
|
*/
|
|
async _getChatCardData(isPrivate) {
|
|
// Générer la liste des combatants de la scène
|
|
let combatants = []
|
|
let isAttack = this.type === "weapon-attack" || this.type === "monster-attack" || this.type === "spell-attack" || this.type === "miracle-attack"
|
|
if (this.rollData?.isDamage || isAttack) {
|
|
// D'abord, ajouter les combattants du combat actif
|
|
if (game?.combat?.combatants) {
|
|
for (let c of game.combat.combatants) {
|
|
if (c.actorId !== this.actorId) {
|
|
combatants.push({ id: c.id, name: c.name, tokenId: c.token.id })
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ensuite, ajouter tous les tokens de la scène active qui ne sont pas déjà dans la liste
|
|
if (canvas?.scene?.tokens) {
|
|
const existingTokenIds = new Set(combatants.map(c => c.tokenId))
|
|
for (let token of canvas.scene.tokens) {
|
|
if (token.actorId !== this.actorId && !existingTokenIds.has(token.id)) {
|
|
combatants.push({
|
|
id: token.id,
|
|
name: token.name,
|
|
tokenId: token.id
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Récupérer les informations de l'arme pour les attaques réussies
|
|
let weaponDamageOptions = null
|
|
log("Roll type:", this.type, "rollTarget:", this.rollTarget, "Has weapon:", !!this.rollTarget?.weapon)
|
|
if (this.type === "weapon-attack" && this.rollTarget?.weapon) {
|
|
const weapon = this.rollTarget.weapon
|
|
weaponDamageOptions = {
|
|
weaponId: weapon.id,
|
|
weaponName: weapon.name,
|
|
damageM: weapon.system?.damage?.damageM
|
|
}
|
|
log("Weapon damage options:", weaponDamageOptions)
|
|
} else if (this.type === "monster-attack" && this.rollTarget) {
|
|
weaponDamageOptions = {
|
|
weaponId: this.rollTarget.rollKey,
|
|
weaponName: this.rollTarget.name,
|
|
damageFormula: this.rollTarget.damageDice,
|
|
damageModifier: this.rollTarget.damageModifier,
|
|
isMonster: true
|
|
}
|
|
log("Monster damage options:", weaponDamageOptions)
|
|
}
|
|
|
|
const cardData = {
|
|
css: [SYSTEM.id, "dice-roll"],
|
|
data: this.data,
|
|
diceTotal: this.dice.reduce((t, d) => t + d.total, 0),
|
|
isGM: game.user.isGM,
|
|
formula: this.formula,
|
|
titleFormula: this.titleFormula,
|
|
rollName: this.rollName,
|
|
rollType: this.type,
|
|
rollTarget: this.rollTarget,
|
|
total: this.rollTotal,
|
|
isFailure: this.isFailure,
|
|
actorId: this.actorId,
|
|
diceResults: this.diceResults,
|
|
actingCharName: this.actorName,
|
|
actingCharImg: this.actorImage,
|
|
resultType: this.resultType,
|
|
hasTarget: this.hasTarget,
|
|
targetName: this.targetName,
|
|
targetArmor: this.targetArmor,
|
|
D30result: this.D30result,
|
|
D30message: this.D30message,
|
|
badResult: this.badResult,
|
|
rollData: this.rollData,
|
|
isPrivate: isPrivate,
|
|
combatants: combatants,
|
|
weaponDamageOptions: weaponDamageOptions,
|
|
isAttack: isAttack,
|
|
defenderId: this.defenderId,
|
|
// Vérifier si l'utilisateur peut sélectionner une cible (est GM ou possède l'acteur)
|
|
canSelectTarget: game.user.isGM || game.actors.get(this.actorId)?.testUserPermission(game.user, "OWNER")
|
|
}
|
|
cardData.cssClass = cardData.css.join(" ")
|
|
cardData.tooltip = isPrivate ? "" : await this.getTooltip()
|
|
return cardData
|
|
}
|
|
|
|
/**
|
|
* Converts the roll result to a chat message.
|
|
*
|
|
* @param {Object} [messageData={}] Additional data to include in the message.
|
|
* @param {Object} options Options for message creation.
|
|
* @param {string} options.messageMode The mode of the roll (e.g., public, private).
|
|
* @param {boolean} [options.create=true] Whether to create the message.
|
|
* @returns {Promise} - A promise that resolves when the message is created.
|
|
*/
|
|
async toMessage(messageData = {}, { messageMode, create = true } = {}) {
|
|
return await super.toMessage(
|
|
{
|
|
isSave: this.isSave,
|
|
isChallenge: this.isChallenge,
|
|
isFailure: this.resultType === "failure",
|
|
rollType: this.type,
|
|
rollTarget: this.rollTarget,
|
|
actingCharName: this.actorName,
|
|
actingCharImg: this.actorImage,
|
|
hasTarget: this.hasTarget,
|
|
targetName: this.targetName,
|
|
targetArmor: this.targetArmor,
|
|
targetMalus: this.targetMalus,
|
|
realDamage: this.realDamage,
|
|
rollData: this.rollData,
|
|
...messageData,
|
|
},
|
|
{ messageMode, create },
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Evaluate a spell/miracle damage formula with per-die explosion, then post to chat.
|
|
* Explosion dice are shown manually via showForRoll; the main roll is shown automatically
|
|
* by toMessage() (which triggers Dice So Nice via its createChatMessage hook).
|
|
* Append "NE" to the formula to disable explosion.
|
|
*
|
|
* @param {string} formula Dice formula, e.g. "1d8", "2d6", "1d8NE"
|
|
* @param {Object} rollOpts Options for LethalFantasyRoll (rollType, actorId, defenderId, etc.)
|
|
* @returns {Promise<ChatMessage>}
|
|
*/
|
|
static async rollSpellDamageToMessage(formula, rollOpts) {
|
|
const roll = new LethalFantasyRoll(formula, {}, rollOpts)
|
|
await roll.evaluate()
|
|
const shouldExplode = !/NE$/i.test(formula)
|
|
const diceResults = []
|
|
let diceSum = 0
|
|
for (const term of roll.dice) {
|
|
const singleDice = `1D${term.faces}`
|
|
for (const r of term.results) {
|
|
let diceResult = r.result
|
|
diceResults.push({ dice: singleDice.toUpperCase(), value: diceResult })
|
|
diceSum += diceResult
|
|
if (shouldExplode && term.faces > 0) {
|
|
while (diceResult === term.faces) {
|
|
const xr = await new Roll(singleDice).evaluate()
|
|
// Optional chaining guards against unexpected roll structure
|
|
diceResult = xr.dice?.[0]?.results?.[0]?.result ?? (term.faces - 1)
|
|
diceResults.push({ dice: `${singleDice.toUpperCase()}-1`, value: diceResult - 1 })
|
|
diceSum += (diceResult - 1)
|
|
term.results.push({ result: diceResult, active: true })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
roll.options.diceResults = diceResults
|
|
roll.options.rollTotal = diceSum
|
|
return roll.toMessage()
|
|
}
|
|
|
|
}
|