diff --git a/lang/en.json b/lang/en.json
index cf9bdb9..2dd71ed 100644
--- a/lang/en.json
+++ b/lang/en.json
@@ -1081,7 +1081,19 @@
"spellDRDialogMsg": "Enter a damage reduction value to subtract, or click No DR to apply full damage.",
"spellDRLabel": "DR:",
"spellNoDR": "No DR",
- "spellApplyDR": "Apply DR"
+ "spellApplyDR": "Apply DR",
+ "chooseWeapon": "Choose a weapon for your reaction",
+ "chooseSave": "Choose a save type",
+ "chooseBonusDie": "Select a bonus die",
+ "attackRoll": "Attack roll",
+ "currentDefense": "Current defense",
+ "shieldDice": "Shield dice",
+ "shieldDR": "Shield DR",
+ "attacker": "Attacker",
+ "defender": "Defender"
+ },
+ "D30": {
+ "chooseEffect": "Choose a D30 special effect to apply"
},
"EquipmentCategories": {
"ClassKit": "Class Kit",
diff --git a/lethal-fantasy.mjs b/lethal-fantasy.mjs
index 7b38360..446e6ba 100644
--- a/lethal-fantasy.mjs
+++ b/lethal-fantasy.mjs
@@ -15,6 +15,9 @@ import { Macros } from "./module/macros.mjs"
import { setupTextEnrichers } from "./module/enrichers.mjs"
import LethalFantasyUtils, { log } from "./module/utils.mjs"
+// Import chat reaction hooks (renderChatMessageHTML, preCreateChatMessage, defense/attack reactions, resource costing, auto-damage)
+import "./module/hooks/chat-reaction.mjs"
+
Hooks.once("init", function () {
globalThis.SYSTEM = SYSTEM
globalThis.pendingDefenses = new Map()
@@ -217,1216 +220,6 @@ function _findBleedingActors() {
return actors
}
-Hooks.on("renderChatMessageHTML", (message, html, data) => {
- const typeMessage = data.message.flags.lethalFantasy?.typeMessage
- // Message de demande de jet de dés
- if (typeMessage === "askRoll") {
- // Affichage des boutons de jet de dés uniquement pour les joueurs
- if (game.user.isGM) {
- for (const btn of html.querySelectorAll(".ask-roll-dice")) {
- btn.style.display = "none"
- }
- } else {
- for (const btn of html.querySelectorAll(".ask-roll-dice")) {
- btn.addEventListener("click", () => {
- const type = btn.dataset.type
- const value = btn.dataset.value
- const avantage = btn.dataset.avantage ?? "="
- const character = game.user.character
- if (type === SYSTEM.ROLL_TYPE.RESOURCE) character.rollResource(value)
- else if (type === SYSTEM.ROLL_TYPE.SAVE) character.rollSave(value, avantage)
- })
- }
- }
- }
-
- // Gestion du survol et du clic sur les boutons de dégâts pour les GMs
- if (game.user.isGM) {
- // Show damage buttons only for GM
- for (const btn of html.querySelectorAll(".li-apply-wounds")) {
- btn.style.display = "block"
- }
-
- for (const btn of html.querySelectorAll(".apply-wounds-btn")) {
- btn.addEventListener("mouseenter", () => {
- const combatantId = btn.dataset.combatantId
- if (combatantId && game.combat) {
- const combatant = game.combat.combatants.get(combatantId)
- if (combatant?.token) {
- const token = canvas.tokens.get(combatant.token.id)
- if (token) {
- token.control({ releaseOthers: true })
- canvas.animatePan(token.center)
- }
- }
- }
- })
- btn.addEventListener("mouseleave", () => canvas.tokens.releaseAll())
- btn.addEventListener("click", event => LethalFantasyUtils.applyDamage(message, event))
- }
- }
-
- // Gestion du survol et du clic sur les boutons de défense
- for (const btn of html.querySelectorAll(".request-defense-btn")) {
- btn.addEventListener("mouseenter", () => {
- const tokenId = btn.dataset.tokenId
- if (tokenId) {
- const token = canvas.tokens.get(tokenId)
- if (token) {
- token.control({ releaseOthers: true })
- canvas.animatePan(token.center)
- }
- }
- })
- btn.addEventListener("mouseleave", () => canvas.tokens.releaseAll())
-
- // Gestionnaire pour les boutons de demande de défense
- btn.addEventListener("click", event => {
- event.preventDefault()
- event.stopPropagation()
-
- const combatantId = btn.dataset.combatantId
- const tokenId = btn.dataset.tokenId
-
- // Récupérer le combattant soit du combat, soit directement du token
- let combatant = null
- let token = null
-
- if (game.combat && combatantId) {
- combatant = game.combat.combatants.get(combatantId)
- }
-
- // Si pas de combattant trouvé, chercher le token directement
- if (!combatant && tokenId) {
- token = canvas.tokens.get(tokenId)
- if (token) {
- // Créer un pseudo-combattant avec les infos du token
- combatant = {
- actor: token.actor,
- name: token.name,
- token: token,
- actorId: token.actorId
- }
- }
- }
-
- if (!combatant) return
-
- // Récupérer les informations de l'attaquant depuis le message
- const attackerName = message.rolls[0]?.actorName || "Unknown"
- const attackerId = message.rolls[0]?.actorId
- const weaponName = message.rolls[0]?.rollName || "weapon"
- const attackRoll = message.rolls[0]?.rollTotal || 0
- const defenderName = combatant.name
- const attackRollType = message.rolls[0]?.type
- const rollTargetData = message.rolls[0]?.rollTarget
- // For spell/miracle attacks, rollTarget IS the spell item (serialised as its data object).
- // For weapon attacks, rollTarget is a plain skill+weapon object and weapon.id is the weapon.
- const isSpellMiracleAttack = attackRollType === "spell-attack" || attackRollType === "miracle-attack"
- const attackWeaponId = isSpellMiracleAttack
- ? (rollTargetData?._id || rollTargetData?.id)
- : (rollTargetData?.weapon?.id || rollTargetData?.weapon?._id)
- const attackRollKey = rollTargetData?.rollKey
- log(`[LF] request-defense-btn | attackRollType=${attackRollType} defender=${defenderName} defenderType=${combatant.actor?.type}`)
- const attackD30result = message.rolls[0]?.options?.D30result || null
- const attackD30message = message.rolls[0]?.options?.D30message || null
- const attackDiceResults = message.rolls[0]?.options?.diceResults || null
- const attackNaturalRoll = attackDiceResults?.[0]?.value || null
- const damageTier = message.rolls[0]?.options?.damageTier || "standard"
- const attackRerollContext = {
- rollType: message.rolls[0]?.options?.rollType,
- rollTarget: foundry.utils.duplicate(message.rolls[0]?.options?.rollTarget ?? {}),
- actorId: message.rolls[0]?.options?.actorId,
- actorName: message.rolls[0]?.options?.actorName,
- actorImage: message.rolls[0]?.options?.actorImage,
- defenderId: combatant.actor?.id || null,
- defenderTokenId: tokenId || combatant.token?.id || null,
- rollContext: foundry.utils.duplicate(message.rolls[0]?.options?.rollData ?? {})
- }
-
- // Préparer le message de demande de défense
- // isRanged: true si le monstre était en mode ranged (via rollTarget.attackMode stocké dans le roll)
- // OU si l'attaquant utilisait une arme ranged (weapon-attack avec weaponType === "ranged")
- const attacker = game.actors.get(attackerId)
- const rollTargetOptions = message.rolls[0]?.options?.rollTarget
- const attackerWeapon = rollTargetOptions?.weapon
- const isRangedAttack = (rollTargetOptions?.attackMode === "ranged")
- || (attacker?.type === "monster" && attacker.system.attackMode === "ranged")
- || (attackerWeapon?.system?.weaponType === "ranged")
- || (rollTargetOptions?.isRangedAttack === true)
-
- const defenseMsg = {
- type: "requestDefense",
- attackerName,
- attackerId,
- defenderName,
- weaponName,
- attackRoll,
- attackWeaponId,
- attackRollType,
- attackRollKey,
- attackD30result,
- attackD30message,
- attackRerollContext,
- attackNaturalRoll,
- damageTier,
- combatantId,
- tokenId,
- isRanged: isRangedAttack
- }
-
- // Envoyer le message socket à l'utilisateur contrôlant le combatant
- // Only consider active (online) users; fall back to any active GM for unowned/GM monsters.
- let owners = game.users.filter(u => u.active && combatant.actor.testUserPermission(u, "OWNER"))
- if (owners.length === 0) {
- owners = game.users.filter(u => u.active && u.isGM)
- }
-
- // Récupérer l'acteur attaquant pour vérifier qui l'a lancé
- const attackerOwners = attacker ? game.users.filter(u => attacker.testUserPermission(u, "OWNER")).map(u => u.id) : []
-
- // Monsters always need their owner (usually the GM) to roll a save/defense,
- // even if that owner also controls the attacker. Only skip for same-player PC-vs-PC.
- const defenderIsMonster = combatant.actor?.type === "monster"
-
- let messageSent = false
- owners.forEach(owner => {
- // Don't let a player be both attacker and defender for their own PC, unless defending a monster.
- if (attackerOwners.includes(owner.id) && owner.id === game.user.id && !defenderIsMonster) {
- // Ne rien faire - on ne veut pas que l'attaquant se défende contre lui-même
- return
- }
-
- if (owner.id === game.user.id) {
- // Si l'utilisateur actuel est le propriétaire du défenseur (mais pas l'attaquant), appeler directement
- LethalFantasyUtils.showDefenseRequest({ ...defenseMsg, userId: owner.id })
- messageSent = true
- } else {
- // Sinon, envoyer via socket
- game.socket.emit(`system.${SYSTEM.id}`, { ...defenseMsg, userId: owner.id })
- messageSent = true
- }
- })
-
- // Notification pour l'attaquant
- if (messageSent) {
- ui.notifications.info(`Defense request sent to ${defenderName}'s controller`)
- }
- })
- }
-
- // Gestionnaire pour les boutons de jet de dégâts (armes et résultats de combat)
- for (const btn of html.querySelectorAll(".damage-roll-btn, .roll-damage-btn")) {
- btn.addEventListener("click", async event => {
- event.preventDefault()
- event.stopPropagation()
-
- const weaponId = btn.dataset.weaponId
- const attackKey = btn.dataset.attackKey
- const attackerId = btn.dataset.attackerId
- const defenderId = btn.dataset.defenderId
- const defenderTokenId = btn.dataset.defenderTokenId || null
- const extraShieldDr = Number(btn.dataset.extraShieldDr || 0)
- const damageType = btn.dataset.damageType
- const damageFormula = btn.dataset.damageFormula
- const damageModifier = btn.dataset.damageModifier
- const isMonster = btn.dataset.isMonster
- const d30Bleed = btn.dataset.d30Bleed === "true"
- const d30DamageMultiplier = Number(btn.dataset.d30DamageMult) || 1
- const d30DrMultiplier = Number(btn.dataset.d30DrMult) || 1
-
- // Récupérer l'acteur (soit depuis le message, soit depuis attackerId)
- const actor = attackerId ? game.actors.get(attackerId) : game.actors.get(message.rolls[0]?.actorId)
- if (!actor) {
- ui.notifications.error("Actor not found")
- return
- }
-
- // Pour les sorts, rouler les dés de dégâts avec DR manuelle optionnelle
- if (damageType === "spell" && damageFormula) {
- const manualDR = await foundry.applications.api.DialogV2.wait({
- window: { title: game.i18n.localize("LETHALFANTASY.Combat.spellDRDialogTitle") },
- classes: ["lethalfantasy"],
- position: { width: 320 },
- content: `
-
${game.i18n.localize("LETHALFANTASY.Combat.spellDRDialogMsg")}
-
- ${game.i18n.localize("LETHALFANTASY.Combat.spellDRLabel")}
-
-
-
`,
- buttons: [
- {
- action: "noDR",
- label: game.i18n.localize("LETHALFANTASY.Combat.spellNoDR"),
- icon: "fa-solid fa-wand-magic-sparkles",
- callback: () => 0
- },
- {
- action: "applyDR",
- label: game.i18n.localize("LETHALFANTASY.Combat.spellApplyDR"),
- icon: "fa-solid fa-shield",
- callback: (event, button) => Number(button.form?.elements?.manualDr?.value) || 0
- },
- {
- action: "cancel",
- label: game.i18n.localize("LETHALFANTASY.Combat.proceedNo"),
- callback: () => "cancel"
- }
- ],
- rejectClose: false
- })
- if (manualDR === null || manualDR === "cancel") return
- const rollOpts = {
- type: "spell-damage",
- rollType: "spell-damage",
- rollName: damageFormula,
- isDamage: true,
- rollData: { isDamage: true },
- manualDR: manualDR,
- defenderId,
- defenderTokenId,
- actorId: actor.id,
- actorName: actor.name,
- actorImage: actor.img,
- d30Bleed,
- d30DamageMultiplier,
- d30DrMultiplier
- }
- await documents.LethalFantasyRoll.rollSpellDamageToMessage(damageFormula, rollOpts)
- return
- }
-
- // Pour les boutons de résultat de combat (monster damage)
- if (damageType === "monster" && attackKey) {
- await actor.system.prepareMonsterRoll("monster-damage", attackKey, undefined, undefined, undefined, defenderId, defenderTokenId, extraShieldDr, { d30Bleed, d30DamageMultiplier, d30DrMultiplier })
- return
- }
-
- // Pour les monstres, utiliser prepareMonsterRoll
- if (isMonster === "true" || actor.type === "monster") {
- await actor.system.prepareMonsterRoll("monster-damage", weaponId, undefined, undefined, damageModifier, defenderId, defenderTokenId, extraShieldDr, { d30Bleed, d30DamageMultiplier, d30DrMultiplier })
- return
- }
-
- // Pour les personnages, récupérer l'arme
- const weapon = actor.items.get(weaponId)
- if (!weapon) {
- ui.notifications.error("Weapon not found")
- return
- }
-
- // Lancer les dégâts
- const rollType = "weapon-damage"
- await actor.prepareRoll(rollType, weaponId, undefined, defenderId, defenderTokenId, extraShieldDr, { d30Bleed, d30DamageMultiplier, d30DrMultiplier })
- })
- }
-
- // Masquer les boutons de dommages dans les messages de résultat de combat si l'utilisateur n'est pas l'attaquant
- for (const btn of html.querySelectorAll(".roll-damage-btn")) {
- const attackerId = btn.dataset.attackerId
- if (attackerId) {
- const attacker = game.actors.get(attackerId)
- // Masquer le bouton si l'utilisateur n'est pas GM et ne possède pas l'attaquant
- if (!game.user.isGM && !attacker?.testUserPermission(game.user, "OWNER")) {
- btn.style.display = "none"
- }
- }
- }
-})
-
-// Hook pour ajouter les données d'attaque au message de défense
-Hooks.on("preCreateChatMessage", (message) => {
- const rollType = message.rolls[0]?.options?.rollType
-
- // Si c'est un message de défense et qu'on a des données en attente
- if ((rollType === "weapon-defense" || rollType === "monster-defense" || rollType === "save") && game.lethalFantasy?.nextDefenseData) {
- // Ajouter les données dans les flags du message
- message.updateSource({
- [`flags.${SYSTEM.id}.attackData`]: game.lethalFantasy.nextDefenseData
- })
-
- log("Added attack data to defense message:", game.lethalFantasy.nextDefenseData)
-
- // Nettoyer
- delete game.lethalFantasy.nextDefenseData
- }
-})
-
-// Build dice breakdown HTML from a reroll result
-function formatRerollBreakdown(reroll) {
- const breakdown = (reroll.options?.diceResults || [])
- .map(r => `${r.dice} → ${r.value} `)
- .join("")
- const d30 = reroll.options?.D30message
- ? `D30 → ${reroll.options.D30result || "?"} — ${reroll.options.D30message.description}
`
- : ""
- return { breakdown, d30 }
-}
-
-// Hook global pour gérer l'offre de Grit à l'attaquant après une défense
-Hooks.on("createChatMessage", async (message) => {
- const rollType = message.rolls[0]?.options?.rollType
-
- log("Defense hook checking message, rollType:", rollType)
-
- // Vérifier si c'est un message de défense
- if (rollType !== "weapon-defense" && rollType !== "monster-defense" && rollType !== "save") return
-
- // Récupérer les données d'attaque depuis les flags
- const attackData = message.flags?.[SYSTEM.id]?.attackData
-
- log("Defense message confirmed, attackData:", attackData)
-
- if (!attackData) {
- log("No attack data found in message flags")
- return
- }
-
- const {
- attackerId,
- attackRoll,
- attackerName,
- defenderName,
- attackWeaponId,
- attackRollType,
- attackRollKey,
- attackRerollContext,
- attackNaturalRoll,
- damageTier,
- defenderId,
- defenderTokenId
- } = attackData
- let { attackD30message } = attackData
- let defenseRoll = message.rolls[0]?.options?.rollTotal || message.rolls[0]?.total || 0
- let defenseD30message = message.rolls[0]?.options?.D30message || null
-
- log("Processing defense:", { attackRoll, defenseRoll, attackerId, defenderId })
-
- // Attendre l'animation 3D
- if (game?.dice3d) {
- await game.dice3d.waitFor3DAnimationByMessageID(message.id)
- }
-
- // Récupérer le défenseur et l'attaquant
- const defender = game.actors.get(defenderId)
- const attacker = game.actors.get(attackerId)
- const defenseRerollContext = {
- rollType: message.rolls[0]?.options?.rollType,
- rollTarget: foundry.utils.duplicate(message.rolls[0]?.options?.rollTarget ?? {}),
- actorId: message.rolls[0]?.options?.actorId,
- actorName: message.rolls[0]?.options?.actorName,
- actorImage: message.rolls[0]?.options?.actorImage,
- defenderId,
- defenderTokenId,
- rollContext: foundry.utils.duplicate(message.rolls[0]?.options?.rollData ?? {})
- }
-
- const isPrimaryController = actor => {
- if (!actor) return false
- const activePlayerOwners = game.users.filter(u => u.active && !u.isGM && actor.testUserPermission(u, "OWNER"))
- if (activePlayerOwners.length > 0) {
- return activePlayerOwners[0].id === game.user.id
- }
- return game.user.isGM
- }
-
- const createReactionMessage = async (actorDocument, content) => {
- await ChatMessage.create({
- content,
- speaker: ChatMessage.getSpeaker({ actor: actorDocument })
- })
- }
-
- // Detect cross-client scenario: attacker has an active non-GM owner on another client
- const attackerHasNonGMOwner = attacker && game.users.some(u => u.active && !u.isGM && attacker.testUserPermission(u, "OWNER"))
- const attackerIsCrossClient = attackerHasNonGMOwner && !isPrimaryController(attacker)
-
- // Reaction phase — both sides may use grit/luck/shield/mulligan before the outcome is resolved.
- // After a mulligan reroll (either side), the comparison restarts so both sides can react to the new numbers.
- let defenderHandledBonus = false
- let attackerHandledBonus = false
- let shieldReaction = null
- let shieldBlocked = false
- const isSpellOrMiracle = attackRollType === "spell-attack" || attackRollType === "miracle-attack"
-
- // These persist across mulligan restarts (once used they stay consumed)
- const shieldData = LethalFantasyUtils.getShieldReactionData(defender)
- let canRerollDefense = LethalFantasyUtils.hasD30Reroll(defenseD30message)
- let canShieldReact = !!shieldData
- let canAdHocShield = !shieldData
- let attackRollFinal = attackRoll
- let canRerollAttack = LethalFantasyUtils.hasD30Reroll(attackD30message)
- let mulliganRestart = false
- // These persist across mulligan restarts (D30 bonus only applied once)
- let defenseD30Processed = false
- let attackD30Processed = false
- // D30 combat effects for damage application
- let d30Bleed = false
- let d30DamageMultiplier = 1
- let d30DrMultiplier = 1
-
- do {
- mulliganRestart = false
- defenderHandledBonus = false
- attackerHandledBonus = false
-
- // ── D30 bonus dice (defense) — resolved before grit/luck/shield ───────
- if (defenseD30message && !defenseD30Processed && isPrimaryController(defender) && !attackerIsCrossClient) {
- const d30Result = await LethalFantasyUtils.processD30BonusDice(defenseD30message, "defense", null, defender, true)
- if (d30Result.modifier) {
- defenseRoll += d30Result.modifier
- if (d30Result.modifier > 0) {
- await createReactionMessage(defender,
- `${defenderName} gains +${d30Result.modifier} from D30 bonus die for defense.
`
- )
- }
- }
- if (d30Result.specialEffect === "auto") {
- defenseRoll = attackRollFinal + 1 // auto-block
- await createReactionMessage(defender,
- `${defenderName} uses ${d30Result.specialName || "Special Defense"} from D30 — defense automatically succeeds!
`
- )
- }
- if (d30Result.specialEffect === "flag") {
- await createReactionMessage(defender,
- `D30 — ${d30Result.specialName || "Special Effect"} triggered for ${defenderName}!
`
- )
- }
- if (d30Result.specialEffect === "drMultiplier") {
- d30DrMultiplier = d30Result.multiplier
- await createReactionMessage(defender,
- `D30 — Defense grants x${d30Result.multiplier} DR (choose which DR types to multiply when damage is applied)
`
- )
- }
- defenseD30Processed = true
- }
-
- // ── Defense reaction loop ──────────────────────────────────────────────
- // Skip when attacker is cross-client — the socket handler (handleAttackBoosted)
- // will show the defense dialog and create the comparison message.
- if (defender && defenseRoll < attackRollFinal && isPrimaryController(defender) && !isSpellOrMiracle && !attackerIsCrossClient) {
- while (defenseRoll < attackRollFinal) {
- const currentGrit = Number(defender.system?.grit?.current) || 0
- const currentLuck = Number(defender.system?.luck?.current) || 0
- const buttons = []
-
- if (currentGrit > 0) {
- buttons.push({
- action: "grit",
- label: `Spend 1 Grit (+1D6) [${currentGrit} left]`,
- icon: "fa-solid fa-fist-raised",
- callback: () => "grit"
- })
- }
-
- if (currentLuck > 0) {
- buttons.push({
- action: "luck",
- label: `Spend 1 Luck (+1D6) [${currentLuck} left]`,
- icon: "fa-solid fa-clover",
- callback: () => "luck"
- })
- }
-
- buttons.push({
- action: "bonusDie",
- label: "Add bonus die",
- icon: "fa-solid fa-dice",
- callback: () => "bonusDie"
- })
-
- if (canRerollDefense) {
- buttons.push({
- action: "rerollDefense",
- label: "Re-roll defense (Mulligan)",
- icon: "fa-solid fa-rotate-right",
- callback: () => "rerollDefense"
- })
- }
-
- if (canShieldReact) {
- buttons.push({
- action: "shieldReact",
- label: `Roll shield (${shieldData.label})`,
- icon: "fa-solid fa-shield",
- callback: () => "shieldReact"
- })
- } else if (canAdHocShield) {
- buttons.push({
- action: "adHocShield",
- label: "Roll ad-hoc shield (choose dice + DR)",
- icon: "fa-solid fa-shield-halved",
- callback: () => "adHocShield"
- })
- }
-
- buttons.push({
- action: "continue",
- label: "Continue (no defense bonus)",
- icon: "fa-solid fa-forward",
- callback: () => "continue"
- })
-
- const choice = await foundry.applications.api.DialogV2.wait({
- window: { title: "Defense reactions" },
- classes: ["lethalfantasy"],
- content: `
-
-
-
${attackerName} rolled ${attackRollFinal}
-
${defenderName} currently has ${defenseRoll}
- ${defenseD30message ? `
D30 special: ${defenseD30message.description}
` : ""}
-
-
Choose how to improve the defense before resolving the hit.
-
- `,
- buttons,
- rejectClose: false
- })
-
- if (!choice || choice === "continue") break
-
- defenderHandledBonus = true
-
- if (choice === "grit") {
- const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender, total => `${defenderName} spends 1 Grit and rolls ${total} for defense.
`)
- defenseRoll += bonusRoll
- await defender.update({ "system.grit.current": currentGrit - 1 })
- continue
- }
-
- if (choice === "luck") {
- const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender, total => `${defenderName} spends 1 Luck and rolls ${total} for defense.
`)
- defenseRoll += bonusRoll
- await defender.update({ "system.luck.current": currentLuck - 1 })
- continue
- }
-
- if (choice === "bonusDie") {
- const bonusDie = await LethalFantasyUtils.promptCombatBonusDie(defenderName, "attack", defenseRoll, attackRollFinal)
- if (!bonusDie) continue
- const bonusRoll = await LethalFantasyUtils.rollBonusDie(bonusDie, defender, (total, formula) => `${defenderName} adds ${formula.toUpperCase()} and rolls ${total} for defense.
`)
- defenseRoll += bonusRoll
- continue
- }
-
- if (choice === "rerollDefense" && canRerollDefense) {
- const oldDefenseRoll = defenseRoll
- const reroll = await LethalFantasyUtils.rerollConfiguredRoll(defenseRerollContext)
- canRerollDefense = false
- if (!reroll) continue
- defenseRoll = reroll.options?.rollTotal || reroll.total || oldDefenseRoll
- // Build dice breakdown HTML from the reroll
- const { breakdown: rerollBreakdown, d30: rerollD30 } = formatRerollBreakdown(reroll)
- await createReactionMessage(defender,
- `${defenderName} uses Mulligan and re-rolls defense: ${oldDefenseRoll} → ${defenseRoll} .
- ${rerollBreakdown}
${rerollD30}
- Both sides may now react to the new numbers.
`
- )
- // Apply new D30 result on the restart
- if (reroll.options?.D30message) {
- defenseD30message = reroll.options.D30message
- defenseD30Processed = false
- }
- // Restart the full comparison so both sides can react to the new roll
- mulliganRestart = true
- break
- }
-
- if (choice === "shieldReact" && canShieldReact) {
- const shieldBonus = await LethalFantasyUtils.rollBonusDie(shieldData.formula, defender)
- const newDefenseTotal = defenseRoll + shieldBonus
- defenseRoll = newDefenseTotal
- canShieldReact = false
-
- if (newDefenseTotal >= attackRollFinal) {
- shieldBlocked = true
- shieldReaction = {
- damageReduction: shieldData.damageReduction,
- label: shieldData.label,
- bonus: shieldBonus
- }
- await createReactionMessage(
- defender,
- `${defenderName} rolls ${shieldData.label} and adds ${shieldBonus} to defense (${newDefenseTotal} ≥ ${attackRollFinal}). Shield blocked the attack! Both armor DR and shield DR ${shieldData.damageReduction} will apply to damage.
`
- )
- } else {
- shieldReaction = null
- await createReactionMessage(
- defender,
- `${defenderName} rolls ${shieldData.label} and adds ${shieldBonus} to defense (${newDefenseTotal} < ${attackRollFinal}). Shield did not block — normal hit, armor DR only.
`
- )
- }
- }
-
- if (choice === "adHocShield") {
- const adHoc = await LethalFantasyUtils.promptAdHocShield(defenderName, attackRollFinal, defenseRoll)
- if (!adHoc) continue
- const shieldBonus = await LethalFantasyUtils.rollBonusDie(adHoc.formula, defender)
- const newDefenseTotal = defenseRoll + shieldBonus
- defenseRoll = newDefenseTotal
- canShieldReact = false
- canAdHocShield = false
-
- if (newDefenseTotal >= attackRollFinal) {
- shieldBlocked = true
- shieldReaction = {
- damageReduction: adHoc.damageReduction,
- label: `${adHoc.formula.toUpperCase()} shield`,
- bonus: shieldBonus
- }
- await createReactionMessage(
- defender,
- `${defenderName} rolls ${adHoc.formula.toUpperCase()} shield and adds ${shieldBonus} to defense (${newDefenseTotal} ≥ ${attackRollFinal}). Shield blocked the attack! Both armor DR and shield DR ${adHoc.damageReduction} will apply to damage.
`
- )
- } else {
- shieldReaction = null
- await createReactionMessage(
- defender,
- `${defenderName} rolls ${adHoc.formula.toUpperCase()} shield and adds ${shieldBonus} to defense (${newDefenseTotal} < ${attackRollFinal}). Shield did not block — normal hit, armor DR only.
`
- )
- }
- }
- }
- }
-
- if (mulliganRestart) continue
-
- // ── D30 bonus dice (attack) — resolved before grit/luck ────────────────
- if (attackD30message && !attackD30Processed) {
- const preD30AttackRoll = attackRollFinal
- const canDialog = isPrimaryController(attacker)
- const d30Result = await LethalFantasyUtils.processD30BonusDice(attackD30message, "attack", attackNaturalRoll, attacker, canDialog)
- if (d30Result.modifier) {
- attackRollFinal += d30Result.modifier
- if (d30Result.modifier > 0 && canDialog) {
- await createReactionMessage(attacker,
- `${attackerName} gains +${d30Result.modifier} from D30 bonus die for attack.
`
- )
- }
- }
- if (d30Result.specialEffect === "auto") {
- attackRollFinal = defenseRoll + 1 // auto-hit
- if (canDialog) {
- await createReactionMessage(attacker,
- `${attackerName} uses ${d30Result.specialName || "Special Strike"} from D30 — attack automatically hits!
`
- )
- }
- }
- if (d30Result.specialEffect === "flag" && canDialog) {
- await createReactionMessage(attacker,
- `D30 — ${d30Result.specialName || "Special Effect"} triggered for ${attackerName}!
`
- )
- }
- if (d30Result.specialEffect === "bleed") {
- d30Bleed = true
- if (canDialog) {
- await createReactionMessage(attacker,
- `D30 — Bleeding/Internal Injury on hit! Damage past DR will cause a bleeding wound.
`
- )
- }
- }
- if (d30Result.specialEffect === "damageMultiplier") {
- d30DamageMultiplier = d30Result.multiplier
- if (canDialog) {
- await createReactionMessage(attacker,
- `D30 — x${d30Result.multiplier} damage before damage reduction!
`
- )
- }
- }
- attackD30Processed = true
- // If D30 boosted attack past defense, restart so defender can react.
- // Only restart when D30 actually changed the outcome (pre-D30 defender was
- // winning or tied, post-D30 defender is losing).
- if (defender && preD30AttackRoll <= defenseRoll && defenseRoll < attackRollFinal) {
- mulliganRestart = true
- continue
- }
- }
-
- // ── Attack reaction loop ───────────────────────────────────────────────
- if (attacker && attackRollFinal <= defenseRoll && isPrimaryController(attacker)) {
- while (attackRollFinal <= defenseRoll) {
- const currentGrit = Number(attacker.system?.grit?.current) || 0
- const buttons = []
-
- if (currentGrit > 0) {
- buttons.push({
- action: "grit",
- label: `Spend 1 Grit (+1D6) [${currentGrit} left]`,
- icon: "fa-solid fa-fist-raised",
- callback: () => "grit"
- })
- }
-
- buttons.push({
- action: "bonusDie",
- label: "Add bonus die",
- icon: "fa-solid fa-dice",
- callback: () => "bonusDie"
- })
-
- if (canRerollAttack && attackRerollContext) {
- buttons.push({
- action: "rerollAttack",
- label: "Re-roll attack (Mulligan)",
- icon: "fa-solid fa-rotate-right",
- callback: () => "rerollAttack"
- })
- }
-
- buttons.push({
- action: "continue",
- label: "Continue (no attack bonus)",
- icon: "fa-solid fa-forward",
- callback: () => "continue"
- })
-
- const choice = await foundry.applications.api.DialogV2.wait({
- window: { title: "Attack reactions" },
- classes: ["lethalfantasy"],
- content: `
-
-
-
${attackerName} currently has ${attackRollFinal}
-
${defenderName} rolled ${defenseRoll}
- ${attackD30message ? `
D30 special: ${attackD30message.description}
` : ""}
-
-
Choose how to improve the attack before resolving the combat result.
-
- `,
- buttons,
- rejectClose: false
- })
-
- if (!choice || choice === "continue") break
-
- attackerHandledBonus = true
-
- if (choice === "grit") {
- const attackBonus = await LethalFantasyUtils.rollBonusDie("1d6", attacker, total => `${attackerName} spends 1 Grit and rolls ${total} for attack.
`)
- attackRollFinal += attackBonus
- await attacker.update({ "system.grit.current": currentGrit - 1 })
- continue
- }
-
- if (choice === "bonusDie") {
- const bonusDie = await LethalFantasyUtils.promptCombatBonusDie(attackerName, "defense", attackRollFinal, defenseRoll)
- if (!bonusDie) continue
- const attackBonus = await LethalFantasyUtils.rollBonusDie(bonusDie, attacker, (total, formula) => `${attackerName} adds ${formula.toUpperCase()} and rolls ${total} for attack.
`)
- attackRollFinal += attackBonus
- continue
- }
-
- if (choice === "rerollAttack" && canRerollAttack && attackRerollContext) {
- const oldAttackRoll = attackRollFinal
- const reroll = await LethalFantasyUtils.rerollConfiguredRoll(attackRerollContext)
- canRerollAttack = false
- if (!reroll) continue
- attackRollFinal = reroll.options?.rollTotal || reroll.total || oldAttackRoll
- // Build dice breakdown HTML from the reroll
- const { breakdown: rerollBreakdown, d30: rerollD30 } = formatRerollBreakdown(reroll)
- await createReactionMessage(attacker,
- `${attackerName} uses Mulligan and re-rolls attack: ${oldAttackRoll} → ${attackRollFinal} .
- ${rerollBreakdown}
${rerollD30}
- Both sides may now react to the new numbers.
`
- )
- // Apply new D30 result on the restart
- if (reroll.options?.D30message) {
- attackD30message = reroll.options.D30message
- attackD30Processed = false
- }
- // Restart the full comparison so both sides can react to the new roll
- mulliganRestart = true
- break
- }
- }
- }
-
- // Cross-client coordination: only delegate to the defender's client
- // when the attacker boosted past the defense. When no attacker boost
- // occurred, the defender's client already processed the defense via
- // the createChatMessage hook and will create the correct comparison.
- // Sending attackBoosted with stale (unboosted) values would cause
- // the defender to see a duplicate dialog and overwrite the result.
- if (defender && isPrimaryController(attacker)) {
- const defenderOwner = game.users.find(u => u.active && !u.isGM && defender.testUserPermission(u, "OWNER"))
- || game.users.find(u => u.active && u.isGM)
- if (defenderOwner && defenderOwner.id !== game.user.id) {
- // Send attackBoosted when the attacker actually boosted (so defender
- // can respond to the new numbers), OR when the attacker has an active
- // non-GM owner (PC-vs-PC cross-client) — the defender's hook-based
- // processing is suppressed by attackerIsCrossClient, so the socket
- // handler must show the defense dialog instead.
- if (attackerHandledBonus || attackerHasNonGMOwner) {
- const sData = LethalFantasyUtils.getShieldReactionData(defender)
- game.socket.emit(`system.${SYSTEM.id}`, {
- type: "attackBoosted",
- userId: defenderOwner.id,
- attackerName, attackerId, defenderName, defenderId, defenderTokenId,
- attackerHandledBonus, attackRollFinal, defenseRoll, attackWeaponId, attackRollType, attackRollKey,
- shieldDamageReduction: shieldBlocked ? shieldReaction?.damageReduction ?? 0 : 0,
- d30Bleed: d30Bleed ? "true" : "",
- d30DamageMultiplier, d30DrMultiplier,
- damageTier: damageTier || "standard",
- attackD30message,
- defenseD30message,
- hasShield: !!sData,
- shieldLabel: sData?.label || "",
- shieldFormula: sData?.formula || "",
- shieldDr: sData?.damageReduction || 0,
- canAdHocShield: !sData,
- })
- }
- return
- }
- // Same client: restart for defender loop if attacker boosted past defense
- if (defenseRoll < attackRollFinal && attackerHandledBonus) {
- mulliganRestart = true
- }
- }
- } while (mulliganRestart)
-
- const shieldDamageReduction = shieldBlocked ? shieldReaction?.damageReduction ?? 0 : 0
- const outcome = shieldBlocked ? "shielded-hit" : (attackRollFinal > defenseRoll ? "hit" : "miss")
-
- // Only one client should create the comparison message:
- // 1. Attacker boosted → attacker's client creates (or socket handler for cross-client)
- // 2. Defender boosted → defender's client creates
- // 3. Nobody boosted → attacker's client (preferred) or defender's client (fallback if same-client)
- const shouldCreateMessage = attackerHandledBonus
- || (!attackerHandledBonus && defenderHandledBonus)
- || (!attackerHandledBonus && !defenderHandledBonus && (
- (isPrimaryController(defender) && !attackerIsCrossClient)
- || isPrimaryController(attacker)
- ))
-
- if (shouldCreateMessage) {
- log("Creating comparison message", { attackerHandledBonus, defenderHandledBonus, isDefenderOwner: defender.isOwner })
-
- await LethalFantasyUtils.compareAttackDefense({
- attackerName,
- attackerId,
- attackRoll: attackRollFinal,
- attackWeaponId,
- attackRollType,
- attackRollKey,
- defenderName,
- defenderId,
- defenderTokenId,
- defenseRoll,
- outcome,
- shieldDamageReduction,
- d30Bleed: d30Bleed ? "true" : "",
- d30DamageMultiplier: d30DamageMultiplier,
- d30DrMultiplier: d30DrMultiplier,
- damageTier: damageTier || "standard",
- attackD30message
- })
- } else {
- log("Skipping message creation", { attackerHandledBonus, defenderHandledBonus })
- }
-})
-
-// Hook: deduct aether when a spell-attack or spell-power roll is posted to chat
-Hooks.on("createChatMessage", async (message) => {
- if (!["spell-attack", "spell-power"].includes(message.rolls[0]?.options?.rollType)) return
-
- const actorId = message.rolls[0]?.options?.actorId
- if (!actorId) return
- const actor = game.actors.get(actorId)
- if (!actor) return
-
- // Only the primary controller (player owner or GM) handles this
- const activePlayerOwners = game.users.filter(u => u.active && !u.isGM && actor.testUserPermission(u, "OWNER"))
- const isPrimary = activePlayerOwners.length > 0
- ? activePlayerOwners[0].id === game.user.id
- : game.user.isGM
- if (!isPrimary) return
-
- const rollTarget = message.rolls[0]?.options?.rollTarget
- const spellId = rollTarget?.id || rollTarget?._id
- const spell = spellId ? actor.items.get(spellId) : null
- if (!spell || spell.type !== "spell") return
-
- const damageTier = message.rolls[0]?.options?.damageTier || "standard"
- const tierCostMap = { standard: "cost", overpowered: "costOverpowered", overpowered2: "costOverpowered2" }
- const costField = tierCostMap[damageTier] || "cost"
- const cost = Number(spell.system?.[costField]) || 0
- if (cost <= 0) return
-
- const currentAether = Number(actor.system.aetherPoints?.value) || 0
- const newAether = Math.max(0, currentAether - cost)
- await actor.update({ "system.aetherPoints.value": newAether })
-
- const tierLabel = damageTier === "standard" ? "" : ` (${damageTier})`
- await ChatMessage.create({
- content: `🔮 ${actor.name} casts ${spell.name}${tierLabel} — spends ${cost} Aether (${currentAether} → ${newAether}) .
`,
- speaker: ChatMessage.getSpeaker({ actor })
- })
-})
-
-// Hook: deduct grace when a miracle-attack or miracle-power roll is posted to chat
-Hooks.on("createChatMessage", async (message) => {
- if (!["miracle-attack", "miracle-power"].includes(message.rolls[0]?.options?.rollType)) return
-
- const actorId = message.rolls[0]?.options?.actorId
- if (!actorId) return
- const actor = game.actors.get(actorId)
- if (!actor) return
-
- const activePlayerOwners = game.users.filter(u => u.active && !u.isGM && actor.testUserPermission(u, "OWNER"))
- const isPrimary = activePlayerOwners.length > 0
- ? activePlayerOwners[0].id === game.user.id
- : game.user.isGM
- if (!isPrimary) return
-
- const rollTarget = message.rolls[0]?.options?.rollTarget
- const miracleId = rollTarget?.id || rollTarget?._id
- const miracle = miracleId ? actor.items.get(miracleId) : null
- if (!miracle || miracle.type !== "miracle") return
-
- const cost = Number(miracle.system?.level) || 0
- if (cost <= 0) return
-
- const currentGrace = Number(actor.system.divinityPoints?.value) || 0
- const newGrace = Math.max(0, currentGrace - cost)
- await actor.update({ "system.divinityPoints.value": newGrace })
-
- await ChatMessage.create({
- content: `✨ ${actor.name} invokes ${miracle.name} — spends ${cost} Grace (${currentGrace} → ${newGrace}) .
`,
- speaker: ChatMessage.getSpeaker({ actor })
- })
-})
-
-// Hook pour appliquer automatiquement les dégâts si une cible est définie
-Hooks.on("createChatMessage", async (message) => {
- // Vérifier si c'est un message de dégâts avec un defenderId
- const defenderId = message.rolls[0]?.options?.defenderId
- const isDamage = message.rolls[0]?.options?.rollData?.isDamage
-
- log("Auto-damage hook:", { defenderId, isDamage, rollType: message.rolls[0]?.options?.rollType })
-
- if (!defenderId || !isDamage) return
-
- // Récupérer l'attaquant depuis le roll
- const attackerId = message.rolls[0]?.options?.actorId
- const attacker = attackerId ? game.actors.get(attackerId) : null
-
- // Déterminer qui doit appliquer les dégâts :
- // 1. Si l'attaquant a un propriétaire joueur, seul ce joueur applique
- // 2. Si l'attaquant n'a que le MJ comme propriétaire (monstre), seul le MJ applique
- const attackerOwners = attacker ? game.users.filter(u =>
- u.active && !u.isGM && attacker.testUserPermission(u, "OWNER")
- ) : []
-
- let shouldApplyDamage = false
- if (attackerOwners.length > 0) {
- // L'attaquant a des propriétaires joueurs, seul le premier propriétaire applique
- shouldApplyDamage = attackerOwners[0].id === game.user.id
- } else {
- // L'attaquant n'a que le MJ, seul le MJ applique
- shouldApplyDamage = game.user.isGM
- }
-
- if (!shouldApplyDamage) {
- log("Auto-damage hook: Not responsible for applying damage, skipping")
- return
- }
-
- log("Auto-damage hook: Applying damage as responsible user")
-
- // Attendre l'animation 3D avant d'appliquer les dégâts
- if (game?.dice3d) {
- await game.dice3d.waitFor3DAnimationByMessageID(message.id)
- }
-
- // Récupérer le défenseur
- const defender = game.actors.get(defenderId)
- if (!defender) {
- console.warn("Defender not found:", defenderId)
- return
- }
-
- // Récupérer les dégâts (utiliser rollTotal qui contient le total calculé)
- const damageTotal = message.rolls[0]?.options?.rollTotal || message.rolls[0]?.total || 0
- const weaponName = message.rolls[0]?.options?.rollName || "Unknown Weapon"
- const attackerName = message.rolls[0]?.options?.actorName || "Unknown Attacker"
- const rollType = message.rolls[0]?.options?.rollType
-
- // Lire les effets D30
- const d30Bleed = message.rolls[0]?.options?.d30Bleed || false
- const d30DamageMultiplier = message.rolls[0]?.options?.d30DamageMultiplier || 1
- const d30DrMultiplier = message.rolls[0]?.options?.d30DrMultiplier || 1
-
- // Appliquer le multiplicateur de dégâts D30 au total AVANT DR
- const rawDamage = damageTotal * d30DamageMultiplier
-
- // Calculer les DR — les sorts utilisent une DR manuelle saisie par l'utilisateur
- const isSpellDamage = rollType === "spell-damage"
- const manualDR = message.rolls[0]?.options?.manualDR ?? 0
- const extraShieldDr = Number(message.rolls[0]?.options?.extraShieldDr) || 0
-
- // Décomposer les DR en composants
- let baseDR = 0
- let shieldDR = 0
- let magicDR = 0
-
- if (isSpellDamage) {
- baseDR = manualDR
- } else {
- const totalDefDR = defender.computeDamageReduction() || 0
- magicDR = defender.getMagicDR() || 0
- baseDR = totalDefDR - magicDR // naturalDR + armorDR (ou hpDR + combatDR pour les monstres)
- shieldDR = extraShieldDr
- }
-
- // Appliquer le multiplicateur de DR D30 si actif — boîte de dialogue
- let appliedBaseDR = baseDR
- let appliedShieldDR = shieldDR
- let appliedMagicDR = magicDR
-
- if (d30DrMultiplier > 1) {
- const drResult = await (async () => {
- const checks = {
- base: true,
- shield: shieldDR > 0,
- magic: magicDR > 0
- }
- const html = `
-
- `
- const result = await foundry.applications.api.DialogV2.wait({
- window: { title: "Apply D30 DR Multiplier" },
- classes: ["lethalfantasy"],
- content: html,
- buttons: [
- {
- action: "apply",
- label: "Apply Damage",
- icon: "fa-solid fa-check",
- callback: (event, button) => {
- const form = button.form || button.closest("form")
- return {
- applyBase: form.querySelector("#d30-dr-base")?.checked || false,
- applyShield: form.querySelector("#d30-dr-shield")?.checked || false,
- applyMagic: form.querySelector("#d30-dr-magic")?.checked || false
- }
- }
- }
- ],
- rejectClose: false
- })
- return result || { applyBase: false, applyShield: false, applyMagic: false }
- })()
-
- appliedBaseDR = drResult.applyBase ? baseDR * d30DrMultiplier : baseDR
- appliedShieldDR = drResult.applyShield ? shieldDR * d30DrMultiplier : shieldDR
- appliedMagicDR = drResult.applyMagic ? magicDR * d30DrMultiplier : magicDR
- }
-
- const totalDR = appliedBaseDR + appliedShieldDR + appliedMagicDR
- const finalDamage = Math.max(0, rawDamage - totalDR)
-
- // Prefer the token ID stored in roll options (set at attack time when the exact token is known).
- // For unlinked tokens (default for monsters), this ensures we target the right instance even
- // when multiple unlinked copies of the same monster type are in combat.
- const rollDefenderTokenId = message.rolls[0]?.options?.defenderTokenId
- const defenderCombatant = game.combat?.combatants?.find(c => c.actorId === defender.id)
- const defenderTokenId = rollDefenderTokenId
- ?? defenderCombatant?.token?.id
- ?? canvas.tokens?.placeables?.find(t => t.actor?.id === defender.id)?.id
- ?? null
-
- // Apply damage. If the current user does not own the defender (e.g. player hitting a GM monster),
- // route the HP update to the GM via socket. The confirmation message is still created here
- // since all users can create chat messages.
- const applyDamageToActor = async (actor) => {
- await actor.applyDamage(-finalDamage)
- // Create bleeding wound if D30 triggered it
- if (d30Bleed && finalDamage > 0 && actor.system.hp?.wounds) {
- const wounds = foundry.utils.duplicate(actor.system.hp.wounds)
- const slot = wounds.findIndex(w => !w.value && !w.duration)
- if (slot !== -1) {
- wounds[slot] = { value: finalDamage, duration: finalDamage, description: "Bleeding" }
- await actor.update({ "system.hp.wounds": wounds })
- }
- }
- }
-
- if (defender.isOwner) {
- const tokenActor = (defenderTokenId
- ? canvas.tokens?.placeables?.find(t => t.id === defenderTokenId)?.actor
- : defenderCombatant?.actor) ?? defender
- await applyDamageToActor(tokenActor)
- } else {
- game.socket.emit(`system.${SYSTEM.id}`, { type: "applyDamage", actorId: defender.id, tokenId: defenderTokenId, damage: -finalDamage })
- // Also emit wound creation for bleeding
- if (d30Bleed && finalDamage > 0 && defender.system.hp?.wounds) {
- game.socket.emit(`system.${SYSTEM.id}`, { type: "applyBleeding", actorId: defender.id, tokenId: defenderTokenId, damage: finalDamage })
- }
- }
-
- // Build DR text for confirmation message
- let drText = ""
- if (isSpellDamage) {
- drText = manualDR > 0 ? `Spell DR: ${manualDR}` : "No DR applied"
- } else {
- const parts = []
- if (appliedBaseDR > 0) parts.push(`Base DR: ${appliedBaseDR}${d30DrMultiplier > 1 && appliedBaseDR !== baseDR ? ` (×${d30DrMultiplier})` : ""}`)
- if (appliedShieldDR > 0) parts.push(`Shield DR: ${appliedShieldDR}${d30DrMultiplier > 1 && appliedShieldDR !== shieldDR ? ` (×${d30DrMultiplier})` : ""}`)
- if (appliedMagicDR > 0) parts.push(`Magic DR: ${appliedMagicDR}${d30DrMultiplier > 1 && appliedMagicDR !== magicDR ? ` (×${d30DrMultiplier})` : ""}`)
- drText = parts.length > 0 ? parts.join(" + ") : "No DR applied"
- }
-
- // Build raw damage text showing D30 multiplier if active
- const rawDamageText = d30DamageMultiplier > 1
- ? `${damageTotal} × ${d30DamageMultiplier} = ${rawDamage}`
- : String(damageTotal)
-
- // Créer un message de confirmation (visible to GM only)
- const messageContent = await foundry.applications.handlebars.renderTemplate(
- "systems/fvtt-lethal-fantasy/templates/damage-applied-message.hbs",
- {
- targetName: defender.name,
- damage: finalDamage,
- drText,
- weaponName: weaponName,
- attackerName: attackerName,
- rawDamage: rawDamageText
- }
- )
-
- // Add bleeding notification
- let bleedContent = ""
- if (d30Bleed && finalDamage > 0) {
- bleedContent = `Bleeding: Wound of ${finalDamage} HP for ${finalDamage} seconds.
`
- }
-
- await ChatMessage.create({
- content: messageContent + bleedContent,
- speaker: ChatMessage.getSpeaker({ actor: defender }),
- whisper: ChatMessage.getWhisperRecipients("GM")
- })
-})
-
-
/**
* Create a macro when dropping an entity on the hotbar
* Item - open roll dialog
@@ -1445,5 +238,3 @@ Hooks.on("hotbarDrop", (bar, data, slot) => {
* Inject the Lethal Fantasy dice tray into the chat sidebar.
*/
Hooks.on("renderChatLog", (_chatLog, html) => applications.injectDiceTray(_chatLog, html))
-
-
diff --git a/module/applications/combat.mjs b/module/applications/combat.mjs
index ea8aa3a..838465c 100644
--- a/module/applications/combat.mjs
+++ b/module/applications/combat.mjs
@@ -208,9 +208,10 @@ export class LethalFantasyCombat extends Combat {
c => c.actor?.type === "monster" && !c.isDefeated && c.initiative !== null && this.round >= c.initiative
);
if (eligibleMonsters.length > 0 && this._monsterProgressionRolledRound !== this.round) {
+ const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/combat-monsters-not-rolled.hbs", {message: game.i18n.localize("LETHALFANTASY.Combat.monstersNotRolledMsg")})
const proceed = await foundry.applications.api.DialogV2.confirm({
window: { title: game.i18n.localize("LETHALFANTASY.Combat.monstersNotRolledTitle") },
- content: `${game.i18n.localize("LETHALFANTASY.Combat.monstersNotRolledMsg")}
`,
+ content,
yes: { label: game.i18n.localize("LETHALFANTASY.Combat.proceedYes") },
no: { label: game.i18n.localize("LETHALFANTASY.Combat.proceedNo") },
rejectClose: false,
diff --git a/module/applications/free-roll.mjs b/module/applications/free-roll.mjs
index 76b89b5..4188cb4 100644
--- a/module/applications/free-roll.mjs
+++ b/module/applications/free-roll.mjs
@@ -16,7 +16,7 @@ const DICE_TYPES = ["d4", "d6", "d8", "d10", "d12", "d20", "d30"]
* @param {Application} _chatLog
* @param {HTMLElement|jQuery} html
*/
-export function injectDiceTray(_chatLog, html) {
+export async function injectDiceTray(_chatLog, html) {
const el = (html instanceof HTMLElement) ? html : (html[0] ?? html)
if (!el?.querySelector) return
if (el.querySelector(".lf-dice-tray")) return
@@ -24,27 +24,14 @@ export function injectDiceTray(_chatLog, html) {
const bar = document.createElement("div")
bar.className = "lf-dice-tray"
- const diceButtons = DICE_TYPES.map(d =>
- `${d.toUpperCase()} `
- ).join("")
-
- const countOptions = Array.from({ length: 9 }, (_, i) =>
- `${i + 1} `
- ).join("")
-
- bar.innerHTML = `
-
-
-
- ${countOptions}
-
-
${diceButtons}
-
-
-
-
-
- `
+ const diceButtons = DICE_TYPES.map(d => ({ value: d, label: d.toUpperCase() }))
+ const countOptions = Array.from({ length: 9 }, (_, i) => i + 1)
+ bar.innerHTML = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/ui/dice-tray.hbs", {
+ countTitle: game.i18n.localize("LETHALFANTASY.DiceTray.CountTitle"),
+ explodeTitle: game.i18n.localize("LETHALFANTASY.DiceTray.ExplodeTitle"),
+ countOptions,
+ diceButtons
+ })
bar.addEventListener("click", async ev => {
const btn = ev.target.closest(".lf-dt-die-btn")
@@ -113,33 +100,19 @@ export async function rollFreeDie(dieType, count = 1, explode = false) {
}
}
- const resultHtml = dieChips.map(chip => {
- const isMax = !chip.exploded && chip.value === sides
- const isMin = chip.value === 1
- const explodeIcon = chip.exploded ? ` ` : ""
- const classes = ["lf-frc-die-chip", isMax ? "lf-frc-max" : "", isMin ? "lf-frc-min" : ""].filter(Boolean).join(" ")
- return `
- ${chip.label}
- →
- ${chip.value}${explodeIcon}
-
`
- }).join("")
+ const dieChipsWithClasses = dieChips.map(chip => ({
+ ...chip,
+ classes: ["lf-frc-die-chip", !chip.exploded && chip.value === sides ? "lf-frc-max" : "", chip.value === 1 ? "lf-frc-min" : ""].filter(Boolean).join(" ")
+ }))
const totalLabel = game.i18n.localize("LETHALFANTASY.Label.total").toUpperCase()
- const content = `
-
-
-
${resultHtml}
-
- ${totalLabel}
- ${total}
-
-
- `
+ const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/free-roll-card.hbs", {
+ titleText: game.i18n.localize("LETHALFANTASY.DiceTray.ChatTitle"),
+ badge: label,
+ dieChips: dieChipsWithClasses,
+ totalLabel,
+ total
+ })
const rollMode = game.settings.get("core", "rollMode")
// Normalize old-style rollMode keys (v12/v13) to new-style (v14), fallback to "public"
diff --git a/module/applications/sheets/character-sheet.mjs b/module/applications/sheets/character-sheet.mjs
index bbead90..a04dac5 100644
--- a/module/applications/sheets/character-sheet.mjs
+++ b/module/applications/sheets/character-sheet.mjs
@@ -241,17 +241,15 @@ export default class LethalFantasyCharacterSheet extends LethalFantasyActorSheet
const formula = formulaMap[tier]
if (!formula) return
+ const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/spell-dr.hbs", {
+ msg: game.i18n.localize("LETHALFANTASY.Combat.spellDRDialogMsg"),
+ label: game.i18n.localize("LETHALFANTASY.Combat.spellDRLabel")
+ })
const manualDR = await foundry.applications.api.DialogV2.wait({
window: { title: game.i18n.localize("LETHALFANTASY.Combat.spellDRDialogTitle") },
classes: ["lethalfantasy"],
position: { width: 320 },
- content: `
-
${game.i18n.localize("LETHALFANTASY.Combat.spellDRDialogMsg")}
-
- ${game.i18n.localize("LETHALFANTASY.Combat.spellDRLabel")}
-
-
-
`,
+ content,
buttons: [
{
action: "noDR",
diff --git a/module/documents/actor.mjs b/module/documents/actor.mjs
index f64b6f9..f3576a4 100644
--- a/module/documents/actor.mjs
+++ b/module/documents/actor.mjs
@@ -225,7 +225,7 @@ export default class LethalFantasyActor extends Actor {
damageTier = await foundry.applications.api.DialogV2.wait({
window: { title: "Choose spell tier" },
classes: ["lethalfantasy"],
- content: `Select the power level for ${rollTarget.name} :
`,
+ content: await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/power-level.hbs", {itemName: rollTarget.name}),
buttons,
rejectClose: false,
}) || "standard"
diff --git a/module/documents/chat-message.mjs b/module/documents/chat-message.mjs
index 4b900f7..6209568 100644
--- a/module/documents/chat-message.mjs
+++ b/module/documents/chat-message.mjs
@@ -13,7 +13,7 @@ export default class LethalFantasyChatMessage extends ChatMessage {
messageData.isWhisper = false
messageData.alias = this.user.name
}
- data.content = ``
+ data.content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/dice-rolls.hbs", {rollHTML})
return
}
diff --git a/module/documents/roll-base.mjs b/module/documents/roll-base.mjs
new file mode 100644
index 0000000..8463a54
--- /dev/null
+++ b/module/documents/roll-base.mjs
@@ -0,0 +1,284 @@
+import { SYSTEM } from "../config/system.mjs"
+import { prompt } from "./roll-prompt.mjs"
+import { promptInitiative, promptCombatAction, promptRangedDefense, promptRangedAttack } from "./roll-combat.mjs"
+import { rollSpellDamageToMessage } from "./roll-damage.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
+ }
+
+ /**
+ * 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 || 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 },
+ )
+ }
+}
+
+// Attach imported prompt methods
+LethalFantasyRoll.prompt = prompt
+LethalFantasyRoll.promptInitiative = promptInitiative
+LethalFantasyRoll.promptCombatAction = promptCombatAction
+LethalFantasyRoll.promptRangedDefense = promptRangedDefense
+LethalFantasyRoll.promptRangedAttack = promptRangedAttack
+LethalFantasyRoll.rollSpellDamageToMessage = rollSpellDamageToMessage
diff --git a/module/documents/roll-combat.mjs b/module/documents/roll-combat.mjs
new file mode 100644
index 0000000..ad23bbe
--- /dev/null
+++ b/module/documents/roll-combat.mjs
@@ -0,0 +1,714 @@
+import { SYSTEM } from "../config/system.mjs"
+import D30Roll from "./d30-roll.mjs"
+import LethalFantasyUtils from "../utils.mjs"
+
+/* ***********************************************************/
+export async function 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 }])
+ }
+}
+
+/* ***********************************************************/
+export async function 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: await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/damage-tier.hbs", {itemName: selectedItem.name}),
+ 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 }) })
+ }
+ }
+ }
+}
+
+/* ***********************************************************/
+export async function 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)
+ await 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)
+
+ 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} The resulting roll, or null if cancelled.
+ */
+export async function 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)
+ await 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
+}
diff --git a/module/documents/roll-damage.mjs b/module/documents/roll-damage.mjs
new file mode 100644
index 0000000..f0eb195
--- /dev/null
+++ b/module/documents/roll-damage.mjs
@@ -0,0 +1,39 @@
+/**
+ * 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}
+ */
+export async function rollSpellDamageToMessage(formula, rollOpts) {
+ const roll = new this(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}`
+ const termResults = Array.from(term.results)
+ for (const r of termResults) {
+ 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()
+}
diff --git a/module/documents/roll-prompt.mjs b/module/documents/roll-prompt.mjs
new file mode 100644
index 0000000..bba33b2
--- /dev/null
+++ b/module/documents/roll-prompt.mjs
@@ -0,0 +1,598 @@
+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
+ }
+}
diff --git a/module/documents/roll.mjs b/module/documents/roll.mjs
index 25da966..f889547 100644
--- a/module/documents/roll.mjs
+++ b/module/documents/roll.mjs
@@ -1,1632 +1 @@
-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} 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("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
- }
- }
-
- /* ***********************************************************/
- 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: `${selectedItem.name} has multiple damage tiers.
Choose which damage to use when the attack lands:
`,
- 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} 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 || 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}
- */
- 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}`
- const termResults = Array.from(term.results)
- for (const r of termResults) {
- 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()
- }
-
-}
+export { default } from "./roll-base.mjs"
diff --git a/module/hooks/chat-reaction.mjs b/module/hooks/chat-reaction.mjs
new file mode 100644
index 0000000..73a5ff4
--- /dev/null
+++ b/module/hooks/chat-reaction.mjs
@@ -0,0 +1,1189 @@
+import { SYSTEM } from "../config/system.mjs"
+import * as documents from "../documents/_module.mjs"
+import LethalFantasyUtils, { log } from "../utils.mjs"
+
+Hooks.on("renderChatMessageHTML", (message, html, data) => {
+ const typeMessage = data.message.flags.lethalFantasy?.typeMessage
+ // Message de demande de jet de dés
+ if (typeMessage === "askRoll") {
+ // Affichage des boutons de jet de dés uniquement pour les joueurs
+ if (game.user.isGM) {
+ for (const btn of html.querySelectorAll(".ask-roll-dice")) {
+ btn.style.display = "none"
+ }
+ } else {
+ for (const btn of html.querySelectorAll(".ask-roll-dice")) {
+ btn.addEventListener("click", () => {
+ const type = btn.dataset.type
+ const value = btn.dataset.value
+ const avantage = btn.dataset.avantage ?? "="
+ const character = game.user.character
+ if (type === SYSTEM.ROLL_TYPE.RESOURCE) character.rollResource(value)
+ else if (type === SYSTEM.ROLL_TYPE.SAVE) character.rollSave(value, avantage)
+ })
+ }
+ }
+ }
+
+ // Gestion du survol et du clic sur les boutons de dégâts pour les GMs
+ if (game.user.isGM) {
+ // Show damage buttons only for GM
+ for (const btn of html.querySelectorAll(".li-apply-wounds")) {
+ btn.style.display = "block"
+ }
+
+ for (const btn of html.querySelectorAll(".apply-wounds-btn")) {
+ btn.addEventListener("mouseenter", () => {
+ const combatantId = btn.dataset.combatantId
+ if (combatantId && game.combat) {
+ const combatant = game.combat.combatants.get(combatantId)
+ if (combatant?.token) {
+ const token = canvas.tokens.get(combatant.token.id)
+ if (token) {
+ token.control({ releaseOthers: true })
+ canvas.animatePan(token.center)
+ }
+ }
+ }
+ })
+ btn.addEventListener("mouseleave", () => canvas.tokens.releaseAll())
+ btn.addEventListener("click", event => LethalFantasyUtils.applyDamage(message, event))
+ }
+ }
+
+ // Gestion du survol et du clic sur les boutons de défense
+ for (const btn of html.querySelectorAll(".request-defense-btn")) {
+ btn.addEventListener("mouseenter", () => {
+ const tokenId = btn.dataset.tokenId
+ if (tokenId) {
+ const token = canvas.tokens.get(tokenId)
+ if (token) {
+ token.control({ releaseOthers: true })
+ canvas.animatePan(token.center)
+ }
+ }
+ })
+ btn.addEventListener("mouseleave", () => canvas.tokens.releaseAll())
+
+ // Gestionnaire pour les boutons de demande de défense
+ btn.addEventListener("click", event => {
+ event.preventDefault()
+ event.stopPropagation()
+
+ const combatantId = btn.dataset.combatantId
+ const tokenId = btn.dataset.tokenId
+
+ // Récupérer le combattant soit du combat, soit directement du token
+ let combatant = null
+ let token = null
+
+ if (game.combat && combatantId) {
+ combatant = game.combat.combatants.get(combatantId)
+ }
+
+ // Si pas de combattant trouvé, chercher le token directement
+ if (!combatant && tokenId) {
+ token = canvas.tokens.get(tokenId)
+ if (token) {
+ // Créer un pseudo-combattant avec les infos du token
+ combatant = {
+ actor: token.actor,
+ name: token.name,
+ token: token,
+ actorId: token.actorId
+ }
+ }
+ }
+
+ if (!combatant) return
+
+ // Récupérer les informations de l'attaquant depuis le message
+ const attackerName = message.rolls[0]?.actorName || "Unknown"
+ const attackerId = message.rolls[0]?.actorId
+ const weaponName = message.rolls[0]?.rollName || "weapon"
+ const attackRoll = message.rolls[0]?.rollTotal || 0
+ const defenderName = combatant.name
+ const attackRollType = message.rolls[0]?.type
+ const rollTargetData = message.rolls[0]?.rollTarget
+ // For spell/miracle attacks, rollTarget IS the spell item (serialised as its data object).
+ // For weapon attacks, rollTarget is a plain skill+weapon object and weapon.id is the weapon.
+ const isSpellMiracleAttack = attackRollType === "spell-attack" || attackRollType === "miracle-attack"
+ const attackWeaponId = isSpellMiracleAttack
+ ? (rollTargetData?._id || rollTargetData?.id)
+ : (rollTargetData?.weapon?.id || rollTargetData?.weapon?._id)
+ const attackRollKey = rollTargetData?.rollKey
+ log(`[LF] request-defense-btn | attackRollType=${attackRollType} defender=${defenderName} defenderType=${combatant.actor?.type}`)
+ const attackD30result = message.rolls[0]?.options?.D30result || null
+ const attackD30message = message.rolls[0]?.options?.D30message || null
+ const attackDiceResults = message.rolls[0]?.options?.diceResults || null
+ const attackNaturalRoll = attackDiceResults?.[0]?.value || null
+ const damageTier = message.rolls[0]?.options?.damageTier || "standard"
+ const attackRerollContext = {
+ rollType: message.rolls[0]?.options?.rollType,
+ rollTarget: foundry.utils.duplicate(message.rolls[0]?.options?.rollTarget ?? {}),
+ actorId: message.rolls[0]?.options?.actorId,
+ actorName: message.rolls[0]?.options?.actorName,
+ actorImage: message.rolls[0]?.options?.actorImage,
+ defenderId: combatant.actor?.id || null,
+ defenderTokenId: tokenId || combatant.token?.id || null,
+ rollContext: foundry.utils.duplicate(message.rolls[0]?.options?.rollData ?? {})
+ }
+
+ // Préparer le message de demande de défense
+ // isRanged: true si le monstre était en mode ranged (via rollTarget.attackMode stocké dans le roll)
+ // OU si l'attaquant utilisait une arme ranged (weapon-attack avec weaponType === "ranged")
+ const attacker = game.actors.get(attackerId)
+ const rollTargetOptions = message.rolls[0]?.options?.rollTarget
+ const attackerWeapon = rollTargetOptions?.weapon
+ const isRangedAttack = (rollTargetOptions?.attackMode === "ranged")
+ || (attacker?.type === "monster" && attacker.system.attackMode === "ranged")
+ || (attackerWeapon?.system?.weaponType === "ranged")
+ || (rollTargetOptions?.isRangedAttack === true)
+
+ const defenseMsg = {
+ type: "requestDefense",
+ attackerName,
+ attackerId,
+ defenderName,
+ weaponName,
+ attackRoll,
+ attackWeaponId,
+ attackRollType,
+ attackRollKey,
+ attackD30result,
+ attackD30message,
+ attackRerollContext,
+ attackNaturalRoll,
+ damageTier,
+ combatantId,
+ tokenId,
+ isRanged: isRangedAttack
+ }
+
+ // Envoyer le message socket à l'utilisateur contrôlant le combatant
+ // Only consider active (online) users; fall back to any active GM for unowned/GM monsters.
+ let owners = game.users.filter(u => u.active && combatant.actor.testUserPermission(u, "OWNER"))
+ if (owners.length === 0) {
+ owners = game.users.filter(u => u.active && u.isGM)
+ }
+
+ // Récupérer l'acteur attaquant pour vérifier qui l'a lancé
+ const attackerOwners = attacker ? game.users.filter(u => attacker.testUserPermission(u, "OWNER")).map(u => u.id) : []
+
+ // Monsters always need their owner (usually the GM) to roll a save/defense,
+ // even if that owner also controls the attacker. Only skip for same-player PC-vs-PC.
+ const defenderIsMonster = combatant.actor?.type === "monster"
+
+ let messageSent = false
+ owners.forEach(owner => {
+ // Don't let a player be both attacker and defender for their own PC, unless defending a monster.
+ if (attackerOwners.includes(owner.id) && owner.id === game.user.id && !defenderIsMonster) {
+ // Ne rien faire - on ne veut pas que l'attaquant se défende contre lui-même
+ return
+ }
+
+ if (owner.id === game.user.id) {
+ // Si l'utilisateur actuel est le propriétaire du défenseur (mais pas l'attaquant), appeler directement
+ LethalFantasyUtils.showDefenseRequest({ ...defenseMsg, userId: owner.id })
+ messageSent = true
+ } else {
+ // Sinon, envoyer via socket
+ game.socket.emit(`system.${SYSTEM.id}`, { ...defenseMsg, userId: owner.id })
+ messageSent = true
+ }
+ })
+
+ // Notification pour l'attaquant
+ if (messageSent) {
+ ui.notifications.info(`Defense request sent to ${defenderName}'s controller`)
+ }
+ })
+ }
+
+ // Gestionnaire pour les boutons de jet de dégâts (armes et résultats de combat)
+ for (const btn of html.querySelectorAll(".damage-roll-btn, .roll-damage-btn")) {
+ btn.addEventListener("click", async event => {
+ event.preventDefault()
+ event.stopPropagation()
+
+ const weaponId = btn.dataset.weaponId
+ const attackKey = btn.dataset.attackKey
+ const attackerId = btn.dataset.attackerId
+ const defenderId = btn.dataset.defenderId
+ const defenderTokenId = btn.dataset.defenderTokenId || null
+ const extraShieldDr = Number(btn.dataset.extraShieldDr || 0)
+ const damageType = btn.dataset.damageType
+ const damageFormula = btn.dataset.damageFormula
+ const damageModifier = btn.dataset.damageModifier
+ const isMonster = btn.dataset.isMonster
+ const d30Bleed = btn.dataset.d30Bleed === "true"
+ const d30DamageMultiplier = Number(btn.dataset.d30DamageMult) || 1
+ const d30DrMultiplier = Number(btn.dataset.d30DrMult) || 1
+
+ // Récupérer l'acteur (soit depuis le message, soit depuis attackerId)
+ const actor = attackerId ? game.actors.get(attackerId) : game.actors.get(message.rolls[0]?.actorId)
+ if (!actor) {
+ ui.notifications.error("Actor not found")
+ return
+ }
+
+ // Pour les sorts, rouler les dés de dégâts avec DR manuelle optionnelle
+ if (damageType === "spell" && damageFormula) {
+ const manualDR = await foundry.applications.api.DialogV2.wait({
+ window: { title: game.i18n.localize("LETHALFANTASY.Combat.spellDRDialogTitle") },
+ classes: ["lethalfantasy"],
+ position: { width: 320 },
+ content: await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/spell-dr.hbs", {
+ msg: game.i18n.localize("LETHALFANTASY.Combat.spellDRDialogMsg"),
+ label: game.i18n.localize("LETHALFANTASY.Combat.spellDRLabel")
+ }),
+ buttons: [
+ {
+ action: "noDR",
+ label: game.i18n.localize("LETHALFANTASY.Combat.spellNoDR"),
+ icon: "fa-solid fa-wand-magic-sparkles",
+ callback: () => 0
+ },
+ {
+ action: "applyDR",
+ label: game.i18n.localize("LETHALFANTASY.Combat.spellApplyDR"),
+ icon: "fa-solid fa-shield",
+ callback: (event, button) => Number(button.form?.elements?.manualDr?.value) || 0
+ },
+ {
+ action: "cancel",
+ label: game.i18n.localize("LETHALFANTASY.Combat.proceedNo"),
+ callback: () => "cancel"
+ }
+ ],
+ rejectClose: false
+ })
+ if (manualDR === null || manualDR === "cancel") return
+ const rollOpts = {
+ type: "spell-damage",
+ rollType: "spell-damage",
+ rollName: damageFormula,
+ isDamage: true,
+ rollData: { isDamage: true },
+ manualDR: manualDR,
+ defenderId,
+ defenderTokenId,
+ actorId: actor.id,
+ actorName: actor.name,
+ actorImage: actor.img,
+ d30Bleed,
+ d30DamageMultiplier,
+ d30DrMultiplier
+ }
+ await documents.LethalFantasyRoll.rollSpellDamageToMessage(damageFormula, rollOpts)
+ return
+ }
+
+ // Pour les boutons de résultat de combat (monster damage)
+ if (damageType === "monster" && attackKey) {
+ await actor.system.prepareMonsterRoll("monster-damage", attackKey, undefined, undefined, undefined, defenderId, defenderTokenId, extraShieldDr, { d30Bleed, d30DamageMultiplier, d30DrMultiplier })
+ return
+ }
+
+ // Pour les monstres, utiliser prepareMonsterRoll
+ if (isMonster === "true" || actor.type === "monster") {
+ await actor.system.prepareMonsterRoll("monster-damage", weaponId, undefined, undefined, damageModifier, defenderId, defenderTokenId, extraShieldDr, { d30Bleed, d30DamageMultiplier, d30DrMultiplier })
+ return
+ }
+
+ // Pour les personnages, récupérer l'arme
+ const weapon = actor.items.get(weaponId)
+ if (!weapon) {
+ ui.notifications.error("Weapon not found")
+ return
+ }
+
+ // Lancer les dégâts
+ const rollType = "weapon-damage"
+ await actor.prepareRoll(rollType, weaponId, undefined, defenderId, defenderTokenId, extraShieldDr, { d30Bleed, d30DamageMultiplier, d30DrMultiplier })
+ })
+ }
+
+ // Masquer les boutons de dommages dans les messages de résultat de combat si l'utilisateur n'est pas l'attaquant
+ for (const btn of html.querySelectorAll(".roll-damage-btn")) {
+ const attackerId = btn.dataset.attackerId
+ if (attackerId) {
+ const attacker = game.actors.get(attackerId)
+ // Masquer le bouton si l'utilisateur n'est pas GM et ne possède pas l'attaquant
+ if (!game.user.isGM && !attacker?.testUserPermission(game.user, "OWNER")) {
+ btn.style.display = "none"
+ }
+ }
+ }
+})
+
+// Hook pour ajouter les données d'attaque au message de défense
+Hooks.on("preCreateChatMessage", (message) => {
+ const rollType = message.rolls[0]?.options?.rollType
+
+ // Si c'est un message de défense et qu'on a des données en attente
+ if ((rollType === "weapon-defense" || rollType === "monster-defense" || rollType === "save") && game.lethalFantasy?.nextDefenseData) {
+ // Ajouter les données dans les flags du message
+ message.updateSource({
+ [`flags.${SYSTEM.id}.attackData`]: game.lethalFantasy.nextDefenseData
+ })
+
+ log("Added attack data to defense message:", game.lethalFantasy.nextDefenseData)
+
+ // Nettoyer
+ delete game.lethalFantasy.nextDefenseData
+ }
+})
+
+// Hook global pour gérer l'offre de Grit à l'attaquant après une défense
+Hooks.on("createChatMessage", async (message) => {
+ const rollType = message.rolls[0]?.options?.rollType
+
+ log("Defense hook checking message, rollType:", rollType)
+
+ // Vérifier si c'est un message de défense
+ if (rollType !== "weapon-defense" && rollType !== "monster-defense" && rollType !== "save") return
+
+ // Récupérer les données d'attaque depuis les flags
+ const attackData = message.flags?.[SYSTEM.id]?.attackData
+
+ log("Defense message confirmed, attackData:", attackData)
+
+ if (!attackData) {
+ log("No attack data found in message flags")
+ return
+ }
+
+ const {
+ attackerId,
+ attackRoll,
+ attackerName,
+ defenderName,
+ attackWeaponId,
+ attackRollType,
+ attackRollKey,
+ attackRerollContext,
+ attackNaturalRoll,
+ damageTier,
+ defenderId,
+ defenderTokenId
+ } = attackData
+ let { attackD30message } = attackData
+ let defenseRoll = message.rolls[0]?.options?.rollTotal || message.rolls[0]?.total || 0
+ let defenseD30message = message.rolls[0]?.options?.D30message || null
+
+ log("Processing defense:", { attackRoll, defenseRoll, attackerId, defenderId })
+
+ // Attendre l'animation 3D
+ if (game?.dice3d) {
+ await game.dice3d.waitFor3DAnimationByMessageID(message.id)
+ }
+
+ // Récupérer le défenseur et l'attaquant
+ const defender = game.actors.get(defenderId)
+ const attacker = game.actors.get(attackerId)
+ const defenseRerollContext = {
+ rollType: message.rolls[0]?.options?.rollType,
+ rollTarget: foundry.utils.duplicate(message.rolls[0]?.options?.rollTarget ?? {}),
+ actorId: message.rolls[0]?.options?.actorId,
+ actorName: message.rolls[0]?.options?.actorName,
+ actorImage: message.rolls[0]?.options?.actorImage,
+ defenderId,
+ defenderTokenId,
+ rollContext: foundry.utils.duplicate(message.rolls[0]?.options?.rollData ?? {})
+ }
+
+ const isPrimaryController = actor => {
+ if (!actor) return false
+ const activePlayerOwners = game.users.filter(u => u.active && !u.isGM && actor.testUserPermission(u, "OWNER"))
+ if (activePlayerOwners.length > 0) {
+ return activePlayerOwners[0].id === game.user.id
+ }
+ return game.user.isGM
+ }
+
+ const createReactionMessage = async (actorDocument, data) => {
+ const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", data)
+ await ChatMessage.create({
+ content,
+ speaker: ChatMessage.getSpeaker({ actor: actorDocument })
+ })
+ }
+
+ // Detect cross-client scenario: attacker has an active non-GM owner on another client
+ const attackerHasNonGMOwner = attacker && game.users.some(u => u.active && !u.isGM && attacker.testUserPermission(u, "OWNER"))
+ const attackerIsCrossClient = attackerHasNonGMOwner && !isPrimaryController(attacker)
+
+ // Reaction phase — both sides may use grit/luck/shield/mulligan before the outcome is resolved.
+ // After a mulligan reroll (either side), the comparison restarts so both sides can react to the new numbers.
+ let defenderHandledBonus = false
+ let attackerHandledBonus = false
+ let shieldReaction = null
+ let shieldBlocked = false
+ const isSpellOrMiracle = attackRollType === "spell-attack" || attackRollType === "miracle-attack"
+
+ // These persist across mulligan restarts (once used they stay consumed)
+ const shieldData = LethalFantasyUtils.getShieldReactionData(defender)
+ let canRerollDefense = LethalFantasyUtils.hasD30Reroll(defenseD30message)
+ let canShieldReact = !!shieldData
+ let canAdHocShield = !shieldData
+ let attackRollFinal = attackRoll
+ let canRerollAttack = LethalFantasyUtils.hasD30Reroll(attackD30message)
+ let mulliganRestart = false
+ // These persist across mulligan restarts (D30 bonus only applied once)
+ let defenseD30Processed = false
+ let attackD30Processed = false
+ // D30 combat effects for damage application
+ let d30Bleed = false
+ let d30DamageMultiplier = 1
+ let d30DrMultiplier = 1
+
+ do {
+ mulliganRestart = false
+ defenderHandledBonus = false
+ attackerHandledBonus = false
+
+ // ── D30 bonus dice (defense) — resolved before grit/luck/shield ───────
+ if (defenseD30message && !defenseD30Processed && isPrimaryController(defender) && !attackerIsCrossClient) {
+ const d30Result = await LethalFantasyUtils.processD30BonusDice(defenseD30message, "defense", null, defender, true)
+ if (d30Result.modifier) {
+ defenseRoll += d30Result.modifier
+ if (d30Result.modifier > 0) {
+ await createReactionMessage(defender, {type:"d30Bonus", actorName:defenderName, value:d30Result.modifier, side:"defense"})
+ }
+ }
+ if (d30Result.specialEffect === "auto") {
+ defenseRoll = attackRollFinal + 1 // auto-block
+ await createReactionMessage(defender, {type:"d30Auto", actorName:defenderName, specialName:d30Result.specialName||"Special Defense", side:"defense"})
+ }
+ if (d30Result.specialEffect === "flag") {
+ await createReactionMessage(defender, {type:"d30Flag", actorName:defenderName, specialName:d30Result.specialName||"Special Effect"})
+ }
+ if (d30Result.specialEffect === "drMultiplier") {
+ d30DrMultiplier = d30Result.multiplier
+ await createReactionMessage(defender, {type:"d30DRMultiplier", actorName:defenderName, value:d30Result.multiplier})
+ }
+ defenseD30Processed = true
+ }
+
+ // ── Defense reaction loop ──────────────────────────────────────────────
+ // Skip when attacker is cross-client — the socket handler (handleAttackBoosted)
+ // will show the defense dialog and create the comparison message.
+ if (defender && defenseRoll < attackRollFinal && isPrimaryController(defender) && !isSpellOrMiracle && !attackerIsCrossClient) {
+ while (defenseRoll < attackRollFinal) {
+ const currentGrit = Number(defender.system?.grit?.current) || 0
+ const currentLuck = Number(defender.system?.luck?.current) || 0
+ const buttons = []
+
+ if (currentGrit > 0) {
+ buttons.push({
+ action: "grit",
+ label: `Spend 1 Grit (+1D6) [${currentGrit} left]`,
+ icon: "fa-solid fa-fist-raised",
+ callback: () => "grit"
+ })
+ }
+
+ if (currentLuck > 0) {
+ buttons.push({
+ action: "luck",
+ label: `Spend 1 Luck (+1D6) [${currentLuck} left]`,
+ icon: "fa-solid fa-clover",
+ callback: () => "luck"
+ })
+ }
+
+ buttons.push({
+ action: "bonusDie",
+ label: "Add bonus die",
+ icon: "fa-solid fa-dice",
+ callback: () => "bonusDie"
+ })
+
+ if (canRerollDefense) {
+ buttons.push({
+ action: "rerollDefense",
+ label: "Re-roll defense (Mulligan)",
+ icon: "fa-solid fa-rotate-right",
+ callback: () => "rerollDefense"
+ })
+ }
+
+ if (canShieldReact) {
+ buttons.push({
+ action: "shieldReact",
+ label: `Roll shield (${shieldData.label})`,
+ icon: "fa-solid fa-shield",
+ callback: () => "shieldReact"
+ })
+ } else if (canAdHocShield) {
+ buttons.push({
+ action: "adHocShield",
+ label: "Roll ad-hoc shield (choose dice + DR)",
+ icon: "fa-solid fa-shield-halved",
+ callback: () => "adHocShield"
+ })
+ }
+
+ buttons.push({
+ action: "continue",
+ label: "Continue (no defense bonus)",
+ icon: "fa-solid fa-forward",
+ callback: () => "continue"
+ })
+
+ const dialogContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/defense-reaction.hbs", {
+ attackerName,
+ attackStatus: "rolled",
+ attackRoll: attackRollFinal,
+ defenderName,
+ defenseStatus: "currently has",
+ defenseRoll,
+ d30message: defenseD30message || null,
+ offerText: "Choose how to improve the defense before resolving the hit."
+ })
+ const choice = await foundry.applications.api.DialogV2.wait({
+ window: { title: "Defense reactions" },
+ classes: ["lethalfantasy"],
+ content: dialogContent,
+ buttons,
+ rejectClose: false
+ })
+
+ if (!choice || choice === "continue") break
+
+ defenderHandledBonus = true
+
+ if (choice === "grit") {
+ const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender)
+ defenseRoll += bonusRoll
+ await defender.update({ "system.grit.current": currentGrit - 1 })
+ await createReactionMessage(defender, {type:"grit", actorName:defenderName, resource:"Grit", value:bonusRoll, side:"defense"})
+ continue
+ }
+
+ if (choice === "luck") {
+ const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender)
+ defenseRoll += bonusRoll
+ await defender.update({ "system.luck.current": currentLuck - 1 })
+ await createReactionMessage(defender, {type:"luck", actorName:defenderName, resource:"Luck", value:bonusRoll, side:"defense"})
+ continue
+ }
+
+ if (choice === "bonusDie") {
+ const bonusDie = await LethalFantasyUtils.promptCombatBonusDie(defenderName, "attack", defenseRoll, attackRollFinal)
+ if (!bonusDie) continue
+ const bonusRoll = await LethalFantasyUtils.rollBonusDie(bonusDie, defender)
+ defenseRoll += bonusRoll
+ await createReactionMessage(defender, {type:"bonusDie", actorName:defenderName, formula:bonusDie.toUpperCase(), value:bonusRoll, side:"defense"})
+ continue
+ }
+
+ if (choice === "rerollDefense" && canRerollDefense) {
+ const oldDefenseRoll = defenseRoll
+ const reroll = await LethalFantasyUtils.rerollConfiguredRoll(defenseRerollContext)
+ canRerollDefense = false
+ if (!reroll) continue
+ defenseRoll = reroll.options?.rollTotal || reroll.total || oldDefenseRoll
+ await createReactionMessage(defender, {
+ type: "mulligan",
+ actorName: defenderName,
+ side: "defense",
+ oldRoll: oldDefenseRoll,
+ newRoll: defenseRoll,
+ diceResults: reroll.options?.diceResults || [],
+ D30result: reroll.options?.D30result,
+ D30message: reroll.options?.D30message
+ })
+ // Apply new D30 result on the restart
+ if (reroll.options?.D30message) {
+ defenseD30message = reroll.options.D30message
+ defenseD30Processed = false
+ }
+ // Restart the full comparison so both sides can react to the new roll
+ mulliganRestart = true
+ break
+ }
+
+ if (choice === "shieldReact" && canShieldReact) {
+ const shieldBonus = await LethalFantasyUtils.rollBonusDie(shieldData.formula, defender)
+ const newDefenseTotal = defenseRoll + shieldBonus
+ defenseRoll = newDefenseTotal
+ canShieldReact = false
+
+ if (newDefenseTotal >= attackRollFinal) {
+ shieldBlocked = true
+ shieldReaction = {
+ damageReduction: shieldData.damageReduction,
+ label: shieldData.label,
+ bonus: shieldBonus
+ }
+ await createReactionMessage(defender, {type:"shieldBlock", actorName:defenderName, shieldLabel:shieldData.label, shieldBonus, newTotal:newDefenseTotal, opposingRoll:attackRollFinal, shieldDR:shieldData.damageReduction})
+ } else {
+ shieldReaction = null
+ await createReactionMessage(defender, {type:"shieldFail", actorName:defenderName, shieldLabel:shieldData.label, shieldBonus, newTotal:newDefenseTotal, opposingRoll:attackRollFinal})
+ }
+ }
+
+ if (choice === "adHocShield") {
+ const adHoc = await LethalFantasyUtils.promptAdHocShield(defenderName, attackRollFinal, defenseRoll)
+ if (!adHoc) continue
+ const shieldBonus = await LethalFantasyUtils.rollBonusDie(adHoc.formula, defender)
+ const newDefenseTotal = defenseRoll + shieldBonus
+ defenseRoll = newDefenseTotal
+ canShieldReact = false
+ canAdHocShield = false
+
+ if (newDefenseTotal >= attackRollFinal) {
+ shieldBlocked = true
+ shieldReaction = {
+ damageReduction: adHoc.damageReduction,
+ label: `${adHoc.formula.toUpperCase()} shield`,
+ bonus: shieldBonus
+ }
+ await createReactionMessage(defender, {type:"shieldBlock", actorName:defenderName, shieldLabel:`${adHoc.formula.toUpperCase()} shield`, shieldBonus, newTotal:newDefenseTotal, opposingRoll:attackRollFinal, shieldDR:adHoc.damageReduction})
+ } else {
+ shieldReaction = null
+ await createReactionMessage(defender, {type:"shieldFail", actorName:defenderName, shieldLabel:`${adHoc.formula.toUpperCase()} shield`, shieldBonus, newTotal:newDefenseTotal, opposingRoll:attackRollFinal})
+ }
+ }
+ }
+ }
+
+ if (mulliganRestart) continue
+
+ // ── D30 bonus dice (attack) — resolved before grit/luck ────────────────
+ if (attackD30message && !attackD30Processed) {
+ const preD30AttackRoll = attackRollFinal
+ const canDialog = isPrimaryController(attacker)
+ const d30Result = await LethalFantasyUtils.processD30BonusDice(attackD30message, "attack", attackNaturalRoll, attacker, canDialog)
+ if (d30Result.modifier) {
+ attackRollFinal += d30Result.modifier
+ if (d30Result.modifier > 0 && canDialog) {
+ await createReactionMessage(attacker, {type:"d30Bonus", actorName:attackerName, value:d30Result.modifier, side:"attack"})
+ }
+ }
+ if (d30Result.specialEffect === "auto") {
+ attackRollFinal = defenseRoll + 1 // auto-hit
+ if (canDialog) {
+ await createReactionMessage(attacker, {type:"d30Auto", actorName:attackerName, specialName:d30Result.specialName||"Special Strike", side:"attack"})
+ }
+ }
+ if (d30Result.specialEffect === "flag" && canDialog) {
+ await createReactionMessage(attacker, {type:"d30Flag", actorName:attackerName, specialName:d30Result.specialName||"Special Effect"})
+ }
+ if (d30Result.specialEffect === "bleed") {
+ d30Bleed = true
+ if (canDialog) {
+ await createReactionMessage(attacker, {type:"d30Bleed", actorName:attackerName})
+ }
+ }
+ if (d30Result.specialEffect === "damageMultiplier") {
+ d30DamageMultiplier = d30Result.multiplier
+ if (canDialog) {
+ await createReactionMessage(attacker, {type:"d30DamageMultiplier", actorName:attackerName, value:d30Result.multiplier})
+ }
+ }
+ attackD30Processed = true
+ // If D30 boosted attack past defense, restart so defender can react.
+ // Only restart when D30 actually changed the outcome (pre-D30 defender was
+ // winning or tied, post-D30 defender is losing).
+ if (defender && preD30AttackRoll <= defenseRoll && defenseRoll < attackRollFinal) {
+ mulliganRestart = true
+ continue
+ }
+ }
+
+ // ── Attack reaction loop ───────────────────────────────────────────────
+ if (attacker && attackRollFinal <= defenseRoll && isPrimaryController(attacker)) {
+ while (attackRollFinal <= defenseRoll) {
+ const currentGrit = Number(attacker.system?.grit?.current) || 0
+ const buttons = []
+
+ if (currentGrit > 0) {
+ buttons.push({
+ action: "grit",
+ label: `Spend 1 Grit (+1D6) [${currentGrit} left]`,
+ icon: "fa-solid fa-fist-raised",
+ callback: () => "grit"
+ })
+ }
+
+ buttons.push({
+ action: "bonusDie",
+ label: "Add bonus die",
+ icon: "fa-solid fa-dice",
+ callback: () => "bonusDie"
+ })
+
+ if (canRerollAttack && attackRerollContext) {
+ buttons.push({
+ action: "rerollAttack",
+ label: "Re-roll attack (Mulligan)",
+ icon: "fa-solid fa-rotate-right",
+ callback: () => "rerollAttack"
+ })
+ }
+
+ buttons.push({
+ action: "continue",
+ label: "Continue (no attack bonus)",
+ icon: "fa-solid fa-forward",
+ callback: () => "continue"
+ })
+
+ const dialogContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/attack-reaction.hbs", {
+ attackerName,
+ attackRoll: attackRollFinal,
+ defenderName,
+ defenseRoll,
+ d30message: attackD30message || null,
+ offerText: "Choose how to improve the attack before resolving the combat result."
+ })
+ const choice = await foundry.applications.api.DialogV2.wait({
+ window: { title: "Attack reactions" },
+ classes: ["lethalfantasy"],
+ content: dialogContent,
+ buttons,
+ rejectClose: false
+ })
+
+ if (!choice || choice === "continue") break
+
+ attackerHandledBonus = true
+
+ if (choice === "grit") {
+ const attackBonus = await LethalFantasyUtils.rollBonusDie("1d6", attacker)
+ attackRollFinal += attackBonus
+ await attacker.update({ "system.grit.current": currentGrit - 1 })
+ await createReactionMessage(attacker, {type:"grit", actorName:attackerName, resource:"Grit", value:attackBonus, side:"attack"})
+ continue
+ }
+
+ if (choice === "bonusDie") {
+ const bonusDie = await LethalFantasyUtils.promptCombatBonusDie(attackerName, "defense", attackRollFinal, defenseRoll)
+ if (!bonusDie) continue
+ const attackBonus = await LethalFantasyUtils.rollBonusDie(bonusDie, attacker)
+ attackRollFinal += attackBonus
+ await createReactionMessage(attacker, {type:"bonusDie", actorName:attackerName, formula:bonusDie.toUpperCase(), value:attackBonus, side:"attack"})
+ continue
+ }
+
+ if (choice === "rerollAttack" && canRerollAttack && attackRerollContext) {
+ const oldAttackRoll = attackRollFinal
+ const reroll = await LethalFantasyUtils.rerollConfiguredRoll(attackRerollContext)
+ canRerollAttack = false
+ if (!reroll) continue
+ attackRollFinal = reroll.options?.rollTotal || reroll.total || oldAttackRoll
+ await createReactionMessage(attacker, {
+ type: "mulligan",
+ actorName: attackerName,
+ side: "attack",
+ oldRoll: oldAttackRoll,
+ newRoll: attackRollFinal,
+ diceResults: reroll.options?.diceResults || [],
+ D30result: reroll.options?.D30result,
+ D30message: reroll.options?.D30message
+ })
+ // Apply new D30 result on the restart
+ if (reroll.options?.D30message) {
+ attackD30message = reroll.options.D30message
+ attackD30Processed = false
+ }
+ // Restart the full comparison so both sides can react to the new roll
+ mulliganRestart = true
+ break
+ }
+ }
+ }
+
+ // Cross-client coordination: only delegate to the defender's client
+ // when the attacker boosted past the defense. When no attacker boost
+ // occurred, the defender's client already processed the defense via
+ // the createChatMessage hook and will create the correct comparison.
+ // Sending attackBoosted with stale (unboosted) values would cause
+ // the defender to see a duplicate dialog and overwrite the result.
+ if (defender && isPrimaryController(attacker)) {
+ const defenderOwner = game.users.find(u => u.active && !u.isGM && defender.testUserPermission(u, "OWNER"))
+ || game.users.find(u => u.active && u.isGM)
+ if (defenderOwner && defenderOwner.id !== game.user.id) {
+ // Send attackBoosted when the attacker actually boosted (so defender
+ // can respond to the new numbers), OR when the attacker has an active
+ // non-GM owner (PC-vs-PC cross-client) — the defender's hook-based
+ // processing is suppressed by attackerIsCrossClient, so the socket
+ // handler must show the defense dialog instead.
+ if (attackerHandledBonus || attackerHasNonGMOwner) {
+ const sData = LethalFantasyUtils.getShieldReactionData(defender)
+ game.socket.emit(`system.${SYSTEM.id}`, {
+ type: "attackBoosted",
+ userId: defenderOwner.id,
+ attackerName, attackerId, defenderName, defenderId, defenderTokenId,
+ attackerHandledBonus, attackRollFinal, defenseRoll, attackWeaponId, attackRollType, attackRollKey,
+ shieldDamageReduction: shieldBlocked ? shieldReaction?.damageReduction ?? 0 : 0,
+ d30Bleed: d30Bleed ? "true" : "",
+ d30DamageMultiplier, d30DrMultiplier,
+ damageTier: damageTier || "standard",
+ attackD30message,
+ defenseD30message,
+ hasShield: !!sData,
+ shieldLabel: sData?.label || "",
+ shieldFormula: sData?.formula || "",
+ shieldDr: sData?.damageReduction || 0,
+ canAdHocShield: !sData,
+ })
+ }
+ return
+ }
+ // Same client: restart for defender loop if attacker boosted past defense
+ if (defenseRoll < attackRollFinal && attackerHandledBonus) {
+ mulliganRestart = true
+ }
+ }
+ } while (mulliganRestart)
+
+ const shieldDamageReduction = shieldBlocked ? shieldReaction?.damageReduction ?? 0 : 0
+ const outcome = shieldBlocked ? "shielded-hit" : (attackRollFinal > defenseRoll ? "hit" : "miss")
+
+ // Only one client should create the comparison message:
+ // 1. Attacker boosted → attacker's client creates (or socket handler for cross-client)
+ // 2. Defender boosted → defender's client creates
+ // 3. Nobody boosted → attacker's client (preferred) or defender's client (fallback if same-client)
+ const shouldCreateMessage = attackerHandledBonus
+ || (!attackerHandledBonus && defenderHandledBonus)
+ || (!attackerHandledBonus && !defenderHandledBonus && (
+ (isPrimaryController(defender) && !attackerIsCrossClient)
+ || isPrimaryController(attacker)
+ ))
+
+ if (shouldCreateMessage) {
+ log("Creating comparison message", { attackerHandledBonus, defenderHandledBonus, isDefenderOwner: defender.isOwner })
+
+ await LethalFantasyUtils.compareAttackDefense({
+ attackerName,
+ attackerId,
+ attackRoll: attackRollFinal,
+ attackWeaponId,
+ attackRollType,
+ attackRollKey,
+ defenderName,
+ defenderId,
+ defenderTokenId,
+ defenseRoll,
+ outcome,
+ shieldDamageReduction,
+ d30Bleed: d30Bleed ? "true" : "",
+ d30DamageMultiplier: d30DamageMultiplier,
+ d30DrMultiplier: d30DrMultiplier,
+ damageTier: damageTier || "standard",
+ attackD30message
+ })
+ } else {
+ log("Skipping message creation", { attackerHandledBonus, defenderHandledBonus })
+ }
+})
+
+// Hook: deduct aether when a spell-attack or spell-power roll is posted to chat
+Hooks.on("createChatMessage", async (message) => {
+ if (!["spell-attack", "spell-power"].includes(message.rolls[0]?.options?.rollType)) return
+
+ const actorId = message.rolls[0]?.options?.actorId
+ if (!actorId) return
+ const actor = game.actors.get(actorId)
+ if (!actor) return
+
+ // Only the primary controller (player owner or GM) handles this
+ const activePlayerOwners = game.users.filter(u => u.active && !u.isGM && actor.testUserPermission(u, "OWNER"))
+ const isPrimary = activePlayerOwners.length > 0
+ ? activePlayerOwners[0].id === game.user.id
+ : game.user.isGM
+ if (!isPrimary) return
+
+ const rollTarget = message.rolls[0]?.options?.rollTarget
+ const spellId = rollTarget?.id || rollTarget?._id
+ const spell = spellId ? actor.items.get(spellId) : null
+ if (!spell || spell.type !== "spell") return
+
+ const damageTier = message.rolls[0]?.options?.damageTier || "standard"
+ const tierCostMap = { standard: "cost", overpowered: "costOverpowered", overpowered2: "costOverpowered2" }
+ const costField = tierCostMap[damageTier] || "cost"
+ const cost = Number(spell.system?.[costField]) || 0
+ if (cost <= 0) return
+
+ const currentAether = Number(actor.system.aetherPoints?.value) || 0
+ const newAether = Math.max(0, currentAether - cost)
+ await actor.update({ "system.aetherPoints.value": newAether })
+
+ const tierLabel = damageTier === "standard" ? "" : ` (${damageTier})`
+ const aetherContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {
+ type: "aetherSpend",
+ actorName: actor.name,
+ spellName: spell.name,
+ tierLabel,
+ value: cost,
+ oldValue: currentAether,
+ newValue: newAether
+ })
+ await ChatMessage.create({
+ content: aetherContent,
+ speaker: ChatMessage.getSpeaker({ actor })
+ })
+})
+
+// Hook: deduct grace when a miracle-attack or miracle-power roll is posted to chat
+Hooks.on("createChatMessage", async (message) => {
+ if (!["miracle-attack", "miracle-power"].includes(message.rolls[0]?.options?.rollType)) return
+
+ const actorId = message.rolls[0]?.options?.actorId
+ if (!actorId) return
+ const actor = game.actors.get(actorId)
+ if (!actor) return
+
+ const activePlayerOwners = game.users.filter(u => u.active && !u.isGM && actor.testUserPermission(u, "OWNER"))
+ const isPrimary = activePlayerOwners.length > 0
+ ? activePlayerOwners[0].id === game.user.id
+ : game.user.isGM
+ if (!isPrimary) return
+
+ const rollTarget = message.rolls[0]?.options?.rollTarget
+ const miracleId = rollTarget?.id || rollTarget?._id
+ const miracle = miracleId ? actor.items.get(miracleId) : null
+ if (!miracle || miracle.type !== "miracle") return
+
+ const cost = Number(miracle.system?.level) || 0
+ if (cost <= 0) return
+
+ const currentGrace = Number(actor.system.divinityPoints?.value) || 0
+ const newGrace = Math.max(0, currentGrace - cost)
+ await actor.update({ "system.divinityPoints.value": newGrace })
+
+ const graceContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {
+ type: "graceSpend",
+ actorName: actor.name,
+ spellName: miracle.name,
+ value: cost,
+ oldValue: currentGrace,
+ newValue: newGrace
+ })
+ await ChatMessage.create({
+ content: graceContent,
+ speaker: ChatMessage.getSpeaker({ actor })
+ })
+})
+
+// Hook pour appliquer automatiquement les dégâts si une cible est définie
+Hooks.on("createChatMessage", async (message) => {
+ // Vérifier si c'est un message de dégâts avec un defenderId
+ const defenderId = message.rolls[0]?.options?.defenderId
+ const isDamage = message.rolls[0]?.options?.rollData?.isDamage
+
+ log("Auto-damage hook:", { defenderId, isDamage, rollType: message.rolls[0]?.options?.rollType })
+
+ if (!defenderId || !isDamage) return
+
+ // Récupérer l'attaquant depuis le roll
+ const attackerId = message.rolls[0]?.options?.actorId
+ const attacker = attackerId ? game.actors.get(attackerId) : null
+
+ // Déterminer qui doit appliquer les dégâts :
+ // 1. Si l'attaquant a un propriétaire joueur, seul ce joueur applique
+ // 2. Si l'attaquant n'a que le MJ comme propriétaire (monstre), seul le MJ applique
+ const attackerOwners = attacker ? game.users.filter(u =>
+ u.active && !u.isGM && attacker.testUserPermission(u, "OWNER")
+ ) : []
+
+ let shouldApplyDamage = false
+ if (attackerOwners.length > 0) {
+ // L'attaquant a des propriétaires joueurs, seul le premier propriétaire applique
+ shouldApplyDamage = attackerOwners[0].id === game.user.id
+ } else {
+ // L'attaquant n'a que le MJ, seul le MJ applique
+ shouldApplyDamage = game.user.isGM
+ }
+
+ if (!shouldApplyDamage) {
+ log("Auto-damage hook: Not responsible for applying damage, skipping")
+ return
+ }
+
+ log("Auto-damage hook: Applying damage as responsible user")
+
+ // Attendre l'animation 3D avant d'appliquer les dégâts
+ if (game?.dice3d) {
+ await game.dice3d.waitFor3DAnimationByMessageID(message.id)
+ }
+
+ // Récupérer le défenseur
+ const defender = game.actors.get(defenderId)
+ if (!defender) {
+ console.warn("Defender not found:", defenderId)
+ return
+ }
+
+ // Récupérer les dégâts (utiliser rollTotal qui contient le total calculé)
+ const damageTotal = message.rolls[0]?.options?.rollTotal || message.rolls[0]?.total || 0
+ const weaponName = message.rolls[0]?.options?.rollName || "Unknown Weapon"
+ const attackerName = message.rolls[0]?.options?.actorName || "Unknown Attacker"
+ const rollType = message.rolls[0]?.options?.rollType
+
+ // Lire les effets D30
+ const d30Bleed = message.rolls[0]?.options?.d30Bleed || false
+ const d30DamageMultiplier = message.rolls[0]?.options?.d30DamageMultiplier || 1
+ const d30DrMultiplier = message.rolls[0]?.options?.d30DrMultiplier || 1
+
+ // Appliquer le multiplicateur de dégâts D30 au total AVANT DR
+ const rawDamage = damageTotal * d30DamageMultiplier
+
+ // Calculer les DR — les sorts utilisent une DR manuelle saisie par l'utilisateur
+ const isSpellDamage = rollType === "spell-damage"
+ const manualDR = message.rolls[0]?.options?.manualDR ?? 0
+ const extraShieldDr = Number(message.rolls[0]?.options?.extraShieldDr) || 0
+
+ // Décomposer les DR en composants
+ let baseDR = 0
+ let shieldDR = 0
+ let magicDR = 0
+
+ if (isSpellDamage) {
+ baseDR = manualDR
+ } else {
+ const totalDefDR = defender.computeDamageReduction() || 0
+ magicDR = defender.getMagicDR() || 0
+ baseDR = totalDefDR - magicDR // naturalDR + armorDR (ou hpDR + combatDR pour les monstres)
+ shieldDR = extraShieldDr
+ }
+
+ // Appliquer le multiplicateur de DR D30 si actif — boîte de dialogue
+ let appliedBaseDR = baseDR
+ let appliedShieldDR = shieldDR
+ let appliedMagicDR = magicDR
+
+ if (d30DrMultiplier > 1) {
+ const drResult = await (async () => {
+ const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/d30-dr-multiplier.hbs", {
+ multiplier: d30DrMultiplier,
+ baseDR,
+ shieldDR,
+ magicDR,
+ baseDRMultiplied: baseDR * d30DrMultiplier,
+ shieldDRMultiplied: shieldDR * d30DrMultiplier,
+ magicDRMultiplied: magicDR * d30DrMultiplier,
+ canBase: true,
+ canShield: shieldDR > 0,
+ canMagic: magicDR > 0,
+ baseEnabled: baseDR > 0,
+ shieldEnabled: shieldDR > 0,
+ magicEnabled: magicDR > 0
+ })
+ const result = await foundry.applications.api.DialogV2.wait({
+ window: { title: "Apply D30 DR Multiplier" },
+ classes: ["lethalfantasy"],
+ content,
+ buttons: [
+ {
+ action: "apply",
+ label: "Apply Damage",
+ icon: "fa-solid fa-check",
+ callback: (event, button) => {
+ const form = button.form || button.closest("form")
+ return {
+ applyBase: form.querySelector("#d30-dr-base")?.checked || false,
+ applyShield: form.querySelector("#d30-dr-shield")?.checked || false,
+ applyMagic: form.querySelector("#d30-dr-magic")?.checked || false
+ }
+ }
+ }
+ ],
+ rejectClose: false
+ })
+ return result || { applyBase: false, applyShield: false, applyMagic: false }
+ })()
+
+ appliedBaseDR = drResult.applyBase ? baseDR * d30DrMultiplier : baseDR
+ appliedShieldDR = drResult.applyShield ? shieldDR * d30DrMultiplier : shieldDR
+ appliedMagicDR = drResult.applyMagic ? magicDR * d30DrMultiplier : magicDR
+ }
+
+ const totalDR = appliedBaseDR + appliedShieldDR + appliedMagicDR
+ const finalDamage = Math.max(0, rawDamage - totalDR)
+
+ // Prefer the token ID stored in roll options (set at attack time when the exact token is known).
+ // For unlinked tokens (default for monsters), this ensures we target the right instance even
+ // when multiple unlinked copies of the same monster type are in combat.
+ const rollDefenderTokenId = message.rolls[0]?.options?.defenderTokenId
+ const defenderCombatant = game.combat?.combatants?.find(c => c.actorId === defender.id)
+ const defenderTokenId = rollDefenderTokenId
+ ?? defenderCombatant?.token?.id
+ ?? canvas.tokens?.placeables?.find(t => t.actor?.id === defender.id)?.id
+ ?? null
+
+ // Apply damage. If the current user does not own the defender (e.g. player hitting a GM monster),
+ // route the HP update to the GM via socket. The confirmation message is still created here
+ // since all users can create chat messages.
+ const applyDamageToActor = async (actor) => {
+ await actor.applyDamage(-finalDamage)
+ // Create bleeding wound if D30 triggered it
+ if (d30Bleed && finalDamage > 0 && actor.system.hp?.wounds) {
+ const wounds = foundry.utils.duplicate(actor.system.hp.wounds)
+ const slot = wounds.findIndex(w => !w.value && !w.duration)
+ if (slot !== -1) {
+ wounds[slot] = { value: finalDamage, duration: finalDamage, description: "Bleeding" }
+ await actor.update({ "system.hp.wounds": wounds })
+ }
+ }
+ }
+
+ if (defender.isOwner) {
+ const tokenActor = (defenderTokenId
+ ? canvas.tokens?.placeables?.find(t => t.id === defenderTokenId)?.actor
+ : defenderCombatant?.actor) ?? defender
+ await applyDamageToActor(tokenActor)
+ } else {
+ game.socket.emit(`system.${SYSTEM.id}`, { type: "applyDamage", actorId: defender.id, tokenId: defenderTokenId, damage: -finalDamage })
+ // Also emit wound creation for bleeding
+ if (d30Bleed && finalDamage > 0 && defender.system.hp?.wounds) {
+ game.socket.emit(`system.${SYSTEM.id}`, { type: "applyBleeding", actorId: defender.id, tokenId: defenderTokenId, damage: finalDamage })
+ }
+ }
+
+ // Build DR text for confirmation message
+ let drText = ""
+ if (isSpellDamage) {
+ drText = manualDR > 0 ? `Spell DR: ${manualDR}` : "No DR applied"
+ } else {
+ const parts = []
+ if (appliedBaseDR > 0) parts.push(`Base DR: ${appliedBaseDR}${d30DrMultiplier > 1 && appliedBaseDR !== baseDR ? ` (×${d30DrMultiplier})` : ""}`)
+ if (appliedShieldDR > 0) parts.push(`Shield DR: ${appliedShieldDR}${d30DrMultiplier > 1 && appliedShieldDR !== shieldDR ? ` (×${d30DrMultiplier})` : ""}`)
+ if (appliedMagicDR > 0) parts.push(`Magic DR: ${appliedMagicDR}${d30DrMultiplier > 1 && appliedMagicDR !== magicDR ? ` (×${d30DrMultiplier})` : ""}`)
+ drText = parts.length > 0 ? parts.join(" + ") : "No DR applied"
+ }
+
+ // Build raw damage text showing D30 multiplier if active
+ const rawDamageText = d30DamageMultiplier > 1
+ ? `${damageTotal} × ${d30DamageMultiplier} = ${rawDamage}`
+ : String(damageTotal)
+
+ // Créer un message de confirmation (visible to GM only)
+ const messageContent = await foundry.applications.handlebars.renderTemplate(
+ "systems/fvtt-lethal-fantasy/templates/damage-applied-message.hbs",
+ {
+ targetName: defender.name,
+ damage: finalDamage,
+ drText,
+ weaponName: weaponName,
+ attackerName: attackerName,
+ rawDamage: rawDamageText
+ }
+ )
+
+ // Add bleeding notification
+ let bleedContent = ""
+ if (d30Bleed && finalDamage > 0) {
+ bleedContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", { type: "bleedingNotice", value: finalDamage })
+ }
+
+ await ChatMessage.create({
+ content: messageContent + bleedContent,
+ speaker: ChatMessage.getSpeaker({ actor: defender }),
+ whisper: ChatMessage.getWhisperRecipients("GM")
+ })
+})
diff --git a/module/utils.mjs b/module/utils.mjs
index 3403a14..754ef64 100644
--- a/module/utils.mjs
+++ b/module/utils.mjs
@@ -1,1704 +1,32 @@
-import { SYSTEM } from "./config/system.mjs"
+import { log, loadCompendiumData, loadCompendium, pushCombatOptions, setHookListeners, registerHandlebarsHelpers, getLethargyDice } from "./utils/helpers.mjs"
+import { hasD30Reroll, processD30BonusDice, _rollD30BonusDie, _buildSpecialLabel, _buildSpecialName } from "./utils/d30.mjs"
+import { handleSocketEvent, handleAttackerGritOffer, handleAttackBoosted, showDefenseRequest, compareAttackDefense, applyDamage, offerAttackerGritBonus, getCombatBonusDiceChoices, getShieldReactionData, promptCombatBonusDie, promptAdHocShield, rollBonusDie, rerollConfiguredRoll } from "./utils/combat.mjs"
-export function log(...args) {
- if (game?.settings?.get(game.system.id, "debug")) {
- console.log(...args)
- }
-}
+export { log }
export default class LethalFantasyUtils {
-
- /* -------------------------------------------- */
- static async loadCompendiumData(compendium) {
- const pack = game.packs.get(compendium)
- return await pack?.getDocuments() ?? []
- }
-
- /* -------------------------------------------- */
- static async loadCompendium(compendium, filter = item => true) {
- let compendiumData = await LethalFantasyUtils.loadCompendiumData(compendium)
- return compendiumData.filter(filter)
- }
-
- /* -------------------------------------------- */
- static pushCombatOptions(html, options) {
- options.push({ name: "Reset Progression", condition: true, icon: ' ', callback: target => { game.combat.resetProgression(target.data('combatant-id')); } })
- }
-
- /* -------------------------------------------- */
- static setHookListeners() {
-
- Hooks.on('renderTokenHUD', async (hud, html, data) => {
- if (html.querySelector(".lethal-hp-loss-hud")) return
- // The token/actor is on the HUD application instance, not the third param.
- // hud.token / hud.object gives the Token (PlaceableObject), which has .actor.
- const hudActor = hud.token?.actor ?? hud.object?.actor
- if (!hudActor) return
- // HP Loss Button (existing)
- const lossHPButton = await foundry.applications.handlebars.renderTemplate('systems/fvtt-lethal-fantasy/templates/loss-hp-hud.hbs', {})
- $(html).find('div.left').append(lossHPButton);
- $(html).find('img.lethal-hp-loss-hud').click((event) => {
- event.preventDefault();
- let hpMenu = $(html).find('.hp-loss-wrap')[0]
- if (hpMenu.classList.contains("hp-loss-hud-disabled")) {
- $(html).find('.hp-loss-wrap')[0].classList.add('hp-loss-hud-active');
- $(html).find('.hp-loss-wrap')[0].classList.remove('hp-loss-hud-disabled');
- $(html).find('.hp-loss-wrap')[1].classList.add('hp-loss-hud-active');
- $(html).find('.hp-loss-wrap')[1].classList.remove('hp-loss-hud-disabled');
- $(html).find('.hp-loss-wrap')[2].classList.add('hp-loss-hud-active');
- $(html).find('.hp-loss-wrap')[2].classList.remove('hp-loss-hud-disabled');
- } else {
- $(html).find('.hp-loss-wrap')[0].classList.remove('hp-loss-hud-active');
- $(html).find('.hp-loss-wrap')[0].classList.add('hp-loss-hud-disabled');
- $(html).find('.hp-loss-wrap')[1].classList.remove('hp-loss-hud-active');
- $(html).find('.hp-loss-wrap')[1].classList.add('hp-loss-hud-disabled');
- $(html).find('.hp-loss-wrap')[2].classList.remove('hp-loss-hud-active');
- $(html).find('.hp-loss-wrap')[2].classList.add('hp-loss-hud-disabled');
- }
- })
- $(html).find('.loss-hp-hud-click').click(async (event) => {
- event.preventDefault();
- let hpLoss = event.currentTarget.dataset.hpValue;
- await hudActor.applyDamage(Number(hpLoss));
- $(html).find('.hp-loss-wrap')[0].classList.remove('hp-loss-hud-active');
- $(html).find('.hp-loss-wrap')[0].classList.add('hp-loss-hud-disabled');
- $(html).find('.hp-loss-wrap')[1].classList.remove('hp-loss-hud-active');
- $(html).find('.hp-loss-wrap')[1].classList.add('hp-loss-hud-disabled');
- $(html).find('.hp-loss-wrap')[2].classList.remove('hp-loss-hud-active');
- $(html).find('.hp-loss-wrap')[2].classList.add('hp-loss-hud-disabled');
- })
-
- // HP Gain Button (new)
- const gainHPButton = await foundry.applications.handlebars.renderTemplate('systems/fvtt-lethal-fantasy/templates/gain-hp-hud.hbs', {})
- $(html).find('div.left').append(gainHPButton);
- $(html).find('img.lethal-hp-gain-hud').click((event) => {
- event.preventDefault();
- let hpMenu = $(html).find('.hp-gain-wrap')[0]
- if (hpMenu.classList.contains("hp-gain-hud-disabled")) {
- $(html).find('.hp-gain-wrap')[0].classList.add('hp-gain-hud-active');
- $(html).find('.hp-gain-wrap')[0].classList.remove('hp-gain-hud-disabled');
- $(html).find('.hp-gain-wrap')[1].classList.add('hp-gain-hud-active');
- $(html).find('.hp-gain-wrap')[1].classList.remove('hp-gain-hud-disabled');
- $(html).find('.hp-gain-wrap')[2].classList.add('hp-gain-hud-active');
- $(html).find('.hp-gain-wrap')[2].classList.remove('hp-gain-hud-disabled');
- } else {
- $(html).find('.hp-gain-wrap')[0].classList.remove('hp-gain-hud-active');
- $(html).find('.hp-gain-wrap')[0].classList.add('hp-gain-hud-disabled');
- $(html).find('.hp-gain-wrap')[1].classList.remove('hp-gain-hud-active');
- $(html).find('.hp-gain-wrap')[1].classList.add('hp-gain-hud-disabled');
- $(html).find('.hp-gain-wrap')[2].classList.remove('hp-gain-hud-active');
- $(html).find('.hp-gain-wrap')[2].classList.add('hp-gain-hud-disabled');
- }
- })
- $(html).find('.gain-hp-hud-click').click(async (event) => {
- event.preventDefault();
- let hpGain = event.currentTarget.dataset.hpValue;
- await hudActor.applyDamage(Number(hpGain)); // Positive value to add HP
- // Clear bleeding wounds on heal — regardless of heal amount, any
- // healing is enough to stop bleeding (field dressing / magic / rest).
- const wounds = foundry.utils.duplicate(hudActor.system.hp.wounds || [])
- const hadBleeding = wounds.some(w => w.description === "Bleeding")
- if (hadBleeding) {
- await hudActor.update({
- "system.hp.wounds": wounds.map(w =>
- w.description === "Bleeding" ? { value: 0, duration: 0 } : w
- )
- })
- }
- $(html).find('.hp-gain-wrap')[0].classList.remove('hp-gain-hud-active');
- $(html).find('.hp-gain-wrap')[0].classList.add('hp-gain-hud-disabled');
- $(html).find('.hp-gain-wrap')[1].classList.remove('hp-gain-hud-active');
- $(html).find('.hp-gain-wrap')[1].classList.add('hp-gain-hud-disabled');
- $(html).find('.hp-gain-wrap')[2].classList.remove('hp-gain-hud-active');
- $(html).find('.hp-gain-wrap')[2].classList.add('hp-gain-hud-disabled');
- })
-
- // Luck/Grit Buttons
- const luckGritButton = await foundry.applications.handlebars.renderTemplate('systems/fvtt-lethal-fantasy/templates/luck-grit-hud.hbs', {})
- $(html).find('div.left').append(luckGritButton);
- $(html).find('.lethal-luck-grit-hud').click((event) => {
- event.preventDefault();
- let wrap = $(html).find('.luck-grit-wrap')[0]
- if (wrap.classList.contains("luck-grit-hud-disabled")) {
- wrap.classList.add('luck-grit-hud-active');
- wrap.classList.remove('luck-grit-hud-disabled');
- } else {
- wrap.classList.remove('luck-grit-hud-active');
- wrap.classList.add('luck-grit-hud-disabled');
- }
- })
- $(html).find('.luck-grit-btn').click(async (event) => {
- event.preventDefault();
- const resource = event.currentTarget.dataset.resource;
- const amount = Number(event.currentTarget.dataset.amount);
- const current = Number(foundry.utils.getProperty(hudActor.system, `${resource}.current`)) || 0;
- const newValue = Math.max(0, current + amount);
- await hudActor.update({ [`system.${resource}.current`]: newValue });
- $(html).find('.luck-grit-wrap')[0].classList.remove('luck-grit-hud-active');
- $(html).find('.luck-grit-wrap')[0].classList.add('luck-grit-hud-disabled');
- })
- })
- }
-
- /* -------------------------------------------- */
- static async handleSocketEvent(msg = {}) {
- log(`handleSocketEvent !`, msg)
- let actor
- switch (msg.type) {
- case "applyDamage":
- if (game.user.isGM) {
- // Prefer the specific token actor (correct for unlinked monsters); fall back to world actor.
- actor = msg.tokenId
- ? canvas.tokens?.placeables?.find(t => t.id === msg.tokenId)?.actor
- : (game.combat?.combatants?.find(c => c.actorId === msg.actorId)?.actor
- ?? game.actors.get(msg.actorId))
- if (actor) await actor.applyDamage(msg.damage)
- }
- break
- case "rollInitiative":
- if (msg.userId && msg.userId !== game.user.id) break
- actor = game.actors.get(msg.actorId)
- await actor.system.rollInitiative(msg.combatId, msg.combatantId)
- break
- case "rollProgressionDice":
- if (msg.userId && msg.userId !== game.user.id) break
- actor = game.actors.get(msg.actorId)
- await actor.system.rollProgressionDice(msg.combatId, msg.combatantId, msg.rollProgressionCount)
- break
- case "requestDefense":
- // Vérifier si le message est destiné à cet utilisateur
- if (msg.userId === game.user.id) {
- LethalFantasyUtils.showDefenseRequest(msg)
- }
- break
- case "offerAttackerGrit":
- // Vérifier si le message est destiné à cet utilisateur
- if (msg.userId === game.user.id) {
- LethalFantasyUtils.handleAttackerGritOffer(msg)
- }
- break
- case "applyBleeding":
- if (game.user.isGM) {
- actor = msg.tokenId
- ? canvas.tokens?.placeables?.find(t => t.id === msg.tokenId)?.actor
- : game.actors.get(msg.actorId)
- if (actor && actor.system.hp?.wounds && msg.damage > 0) {
- const wounds = foundry.utils.duplicate(actor.system.hp.wounds)
- const slot = wounds.findIndex(w => !w.value && !w.duration)
- if (slot !== -1) {
- wounds[slot] = { value: msg.damage, duration: msg.damage, description: "Bleeding" }
- await actor.update({ "system.hp.wounds": wounds })
- }
- }
- }
- break
- case "attackBoosted":
- if (msg.userId === game.user.id) {
- LethalFantasyUtils.handleAttackBoosted(msg)
- }
- break
- }
- }
-
- /* -------------------------------------------- */
- static async handleAttackerGritOffer(msg) {
- const { attackerId, attackRoll, defenseRoll, attackerName, defenderName, attackWeaponId, attackRollType, attackRollKey, defenderId } = msg
-
- const attacker = game.actors.get(attackerId)
- if (!attacker) {
- console.warn("Attacker not found:", attackerId)
- return
- }
-
- const attackBonus = await LethalFantasyUtils.offerAttackerGritBonus(
- attacker,
- attackRoll,
- defenseRoll,
- attackerName,
- defenderName
- )
-
- const attackRollFinal = attackRoll + attackBonus
-
- // Maintenant créer le message de comparaison
- await LethalFantasyUtils.compareAttackDefense({
- attackerName,
- attackerId,
- attackRoll: attackRollFinal,
- attackWeaponId,
- attackRollType,
- attackRollKey,
- defenderName,
- defenderId,
- defenseRoll
- })
- }
-
- /* -------------------------------------------- */
- static async handleAttackBoosted(msg) {
- const {
- attackerName, attackerId, defenderName, defenderId, defenderTokenId,
- attackerHandledBonus, attackRollFinal, defenseRoll, attackWeaponId, attackRollType, attackRollKey,
- shieldDamageReduction: initialShieldDR,
- d30Bleed, d30DamageMultiplier, d30DrMultiplier,
- damageTier, attackD30message, defenseD30message,
- hasShield, shieldLabel, shieldFormula, shieldDr, canAdHocShield
- } = msg
-
- const defender = game.actors.get(defenderId)
- if (!defender) return
-
- let updatedDefenseRoll = defenseRoll
- let shieldBlocked = false
- let shieldReaction = null
- let canShieldReact = hasShield
- let canAdHoc = canAdHocShield
-
- // ── D30 bonus dice (defense) — resolved before grit/luck/shield ───────
- let defenseDrMultiplier = null
- if (defenseD30message && defender) {
- const d30Result = await LethalFantasyUtils.processD30BonusDice(defenseD30message, "defense", null, defender, true)
- if (d30Result.modifier) {
- updatedDefenseRoll += d30Result.modifier
- if (d30Result.modifier > 0) {
- await ChatMessage.create({
- content: `${defenderName} gains +${d30Result.modifier} from D30 bonus die for defense.
`,
- speaker: ChatMessage.getSpeaker({ actor: defender })
- })
- }
- }
- if (d30Result.specialEffect === "auto") {
- updatedDefenseRoll = attackRollFinal + 1
- await ChatMessage.create({
- content: `${defenderName} uses ${d30Result.specialName || "Special Defense"} from D30 — defense automatically succeeds!
`,
- speaker: ChatMessage.getSpeaker({ actor: defender })
- })
- }
- if (d30Result.specialEffect === "flag") {
- await ChatMessage.create({
- content: `D30 — ${d30Result.specialName || "Special Effect"} triggered for ${defenderName}!
`,
- speaker: ChatMessage.getSpeaker({ actor: defender })
- })
- }
- if (d30Result.specialEffect === "drMultiplier") {
- defenseDrMultiplier = d30Result.multiplier
- await ChatMessage.create({
- content: `D30 — Defense grants x${d30Result.multiplier} DR (choose which DR types to multiply when damage is applied)
`,
- speaker: ChatMessage.getSpeaker({ actor: defender })
- })
- }
- }
-
- // Show the defense reaction dialog — while-loop for multiple reactions
- if (defender) {
- while (updatedDefenseRoll < attackRollFinal) {
- const currentGrit = Number(defender.system?.grit?.current) || 0
- const currentLuck = Number(defender.system?.luck?.current) || 0
- const buttons = []
-
- if (currentGrit > 0) {
- buttons.push({
- action: "grit",
- type: "button",
- label: `Spend 1 Grit (+1D6) [${currentGrit} left]`,
- icon: "fa-solid fa-fist-raised",
- callback: () => "grit"
- })
- }
-
- if (currentLuck > 0) {
- buttons.push({
- action: "luck",
- type: "button",
- label: `Spend 1 Luck (+1D6) [${currentLuck} left]`,
- icon: "fa-solid fa-clover",
- callback: () => "luck"
- })
- }
-
- buttons.push({
- action: "bonusDie",
- type: "button",
- label: "Add bonus die",
- icon: "fa-solid fa-dice",
- callback: () => "bonusDie"
- })
-
- if (canShieldReact) {
- buttons.push({
- action: "shieldReact",
- type: "button",
- label: `Roll shield (${shieldLabel})`,
- icon: "fa-solid fa-shield",
- callback: () => "shieldReact"
- })
- } else if (canAdHoc) {
- buttons.push({
- action: "adHocShield",
- type: "button",
- label: "Roll ad-hoc shield (choose dice + DR)",
- icon: "fa-solid fa-shield-halved",
- callback: () => "adHocShield"
- })
- }
-
- buttons.push({
- action: "continue",
- type: "button",
- label: "Continue (no defense bonus)",
- icon: "fa-solid fa-forward",
- callback: () => "continue"
- })
-
- const choice = await foundry.applications.api.DialogV2.wait({
- window: { title: "Defense reactions — attack boosted" },
- classes: ["lethalfantasy"],
- content: `
-
-
-
${attackerName} boosted attack to ${attackRollFinal}
-
${defenderName} currently has ${updatedDefenseRoll}
-
-
The attack was boosted! Choose how to improve the defense.
-
- `,
- buttons,
- rejectClose: false
- })
-
- if (!choice || choice === "continue") break
-
- if (choice === "grit") {
- const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender,
- total => `${defenderName} spends 1 Grit and rolls ${total} for defense.
`)
- updatedDefenseRoll += bonusRoll
- await defender.update({ "system.grit.current": currentGrit - 1 })
- } else if (choice === "luck") {
- const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender,
- total => `${defenderName} spends 1 Luck and rolls ${total} for defense.
`)
- updatedDefenseRoll += bonusRoll
- await defender.update({ "system.luck.current": currentLuck - 1 })
- } else if (choice === "bonusDie") {
- const bonusDie = await LethalFantasyUtils.promptCombatBonusDie(defenderName, "attack", updatedDefenseRoll, attackRollFinal)
- if (bonusDie) {
- const bonusRoll = await LethalFantasyUtils.rollBonusDie(bonusDie, defender,
- (total, formula) => `${defenderName} adds ${formula.toUpperCase()} and rolls ${total} for defense.
`)
- updatedDefenseRoll += bonusRoll
- }
- } else if (choice === "shieldReact" && canShieldReact) {
- const shieldBonus = await LethalFantasyUtils.rollBonusDie(shieldFormula, defender)
- const newDefenseTotal = updatedDefenseRoll + shieldBonus
- updatedDefenseRoll = newDefenseTotal
- canShieldReact = false
- if (newDefenseTotal >= attackRollFinal) {
- shieldBlocked = true
- shieldReaction = { damageReduction: shieldDr, label: shieldLabel, bonus: shieldBonus }
- await ChatMessage.create({
- content: `${defenderName} rolls ${shieldLabel} and adds ${shieldBonus} to defense (${newDefenseTotal} ≥ ${attackRollFinal}). Shield blocked the attack! Both armor DR and shield DR ${shieldDr} will apply to damage.
`,
- speaker: ChatMessage.getSpeaker({ actor: defender })
- })
- } else {
- await ChatMessage.create({
- content: `${defenderName} rolls ${shieldLabel} and adds ${shieldBonus} to defense (${newDefenseTotal} < ${attackRollFinal}). Shield did not block — normal hit, armor DR only.
`,
- speaker: ChatMessage.getSpeaker({ actor: defender })
- })
- }
- } else if (choice === "adHocShield" && canAdHoc) {
- const adHoc = await LethalFantasyUtils.promptAdHocShield(defenderName, attackRollFinal, updatedDefenseRoll)
- if (adHoc) {
- const shieldBonus = await LethalFantasyUtils.rollBonusDie(adHoc.formula, defender)
- const newDefenseTotal = updatedDefenseRoll + shieldBonus
- updatedDefenseRoll = newDefenseTotal
- canShieldReact = false
- canAdHoc = false
- if (newDefenseTotal >= attackRollFinal) {
- shieldBlocked = true
- shieldReaction = { damageReduction: adHoc.damageReduction, label: `${adHoc.formula.toUpperCase()} shield`, bonus: shieldBonus }
- await ChatMessage.create({
- content: `${defenderName} rolls ${adHoc.formula.toUpperCase()} shield and adds ${shieldBonus} to defense (${newDefenseTotal} ≥ ${attackRollFinal}). Shield blocked the attack! Both armor DR and shield DR ${adHoc.damageReduction} will apply to damage.
`,
- speaker: ChatMessage.getSpeaker({ actor: defender })
- })
- } else {
- await ChatMessage.create({
- content: `${defenderName} rolls ${adHoc.formula.toUpperCase()} shield and adds ${shieldBonus} to defense (${newDefenseTotal} < ${attackRollFinal}). Shield did not block — normal hit, armor DR only.
`,
- speaker: ChatMessage.getSpeaker({ actor: defender })
- })
- }
- }
- }
- }
- }
-
- const finalShieldDR = shieldBlocked ? (shieldReaction?.damageReduction || 0) : 0
- const outcome = shieldBlocked ? "shielded-hit" : (updatedDefenseRoll >= attackRollFinal ? "miss" : "hit")
-
- await LethalFantasyUtils.compareAttackDefense({
- attackerName,
- attackerId,
- attackRoll: attackRollFinal,
- attackWeaponId,
- attackRollType,
- attackRollKey,
- defenderName,
- defenderId,
- defenderTokenId,
- defenseRoll: updatedDefenseRoll,
- outcome,
- shieldDamageReduction: finalShieldDR,
- d30Bleed: d30Bleed || "",
- d30DamageMultiplier: d30DamageMultiplier || 1,
- d30DrMultiplier: (d30DrMultiplier != null && d30DrMultiplier !== 1) ? d30DrMultiplier : (defenseDrMultiplier ?? d30DrMultiplier ?? 1),
- damageTier: damageTier || "standard",
- attackD30message
- })
- }
-
- /* -------------------------------------------- */
- static async showDefenseRequest(msg) {
- const attackerName = msg.attackerName
- const attackerId = msg.attackerId
- const defenderName = msg.defenderName
- const weaponName = msg.weaponName || "attack"
- const attackRoll = msg.attackRoll
- const attackWeaponId = msg.attackWeaponId
- const attackRollType = msg.attackRollType
- const attackRollKey = msg.attackRollKey
- const attackD30result = msg.attackD30result
- const attackD30message = msg.attackD30message
- const attackRerollContext = msg.attackRerollContext
- const combatantId = msg.combatantId
- const tokenId = msg.tokenId
-
- // Récupérer le défenseur - essayer d'abord depuis le combat, puis depuis le token
- let defender = null
-
- if (game.combat && combatantId) {
- const combatant = game.combat.combatants.get(combatantId)
- if (combatant) {
- defender = combatant.actor
- }
- }
-
- // Si pas trouvé dans le combat, chercher le token directement
- if (!defender && tokenId) {
- const token = canvas.tokens.get(tokenId)
- if (token) {
- defender = token.actor
- }
- }
-
- if (!defender) {
- ui.notifications.error("Defender actor not found")
- return
- }
-
- // Resolve the specific token ID now while we still have combatant/token context.
- // This is passed through to the damage roll so the GM-side socket handler can find the
- // correct synthetic actor for unlinked tokens (avoids wrong-instance damage with multiple
- // unlinked copies of the same monster).
- const defenderTokenId = (() => {
- if (game.combat && combatantId) {
- const cbt = game.combat.combatants.get(combatantId)
- if (cbt?.token?.id) return cbt.token.id
- }
- return tokenId ?? canvas.tokens?.placeables?.find(t => t.actor?.id === defender.id)?.id ?? null
- })()
-
- const isMonster = defender.type === "monster"
-
- log(`[LF] showDefenseRequest | attackRollType=${attackRollType} isMonster=${isMonster} defender=${defender?.name}`)
-
- // Spell/miracle attacks use saving throws instead of weapon defense
- const isSpellAttack = attackRollType === "spell-attack" || attackRollType === "miracle-attack"
- if (isSpellAttack) {
- const savesConfig = isMonster ? SYSTEM.MONSTER_SAVES : SYSTEM.SAVES
- const combatSaves = ["will", "dodge", "toughness"]
- const savesHTML = Object.values(savesConfig)
- .filter(s => combatSaves.includes(s.id))
- .map(s => `${game.i18n.localize(s.label)} `)
- .join("")
-
- const content = `
-
-
-
${attackerName} targets ${defenderName} with ${weaponName} !
-
Attack roll: ${attackRoll}
-
-
- Choose saving throw:
-
- ${savesHTML}
-
-
-
- `
-
- const result = await foundry.applications.api.DialogV2.wait({
- window: { title: "Saving Throw vs Spell" },
- classes: ["lethalfantasy"],
- content,
- buttons: [
- {
- action: "rollSave",
- type: "button",
- label: "Roll Save",
- icon: "fa-solid fa-person-running",
- callback: (event, button) => button.form.elements.saveKey.value,
- },
- ],
- rejectClose: false
- })
-
- if (result) {
- game.lethalFantasy = game.lethalFantasy || {}
- game.lethalFantasy.spellDefense = true // pré-cocher "Save against spell" dans le dialog
- game.lethalFantasy.nextDefenseData = {
- attackerId,
- attackRoll,
- attackerName,
- defenderName,
- attackWeaponId,
- attackRollType,
- attackRollKey,
- attackD30result,
- attackD30message,
- attackRerollContext,
- attackNaturalRoll: msg.attackNaturalRoll,
- damageTier: msg.damageTier,
- defenderId: defender.id,
- defenderTokenId
- }
- if (isMonster) {
- await defender.system.prepareMonsterRoll("save", result)
- } else {
- await defender.prepareRoll("save", result)
- }
- }
- return
- }
-
- // Pour les monstres, récupérer les attaques activées
- if (isMonster) {
- const attacksSet = defender.system.attackMode === "ranged" ? defender.system.rangedAttacks : defender.system.attacks
- const enabledAttacks = Object.entries(attacksSet).filter(([key, attack]) => attack.enabled)
-
- if (enabledAttacks.length === 0) {
- ui.notifications.warn("No enabled attacks available for defense")
- return
- }
-
- // Créer le contenu du dialogue pour monstre
- let attacksHTML = enabledAttacks.map(([key, attack]) =>
- `${attack.name} `
- ).join("")
-
- const content = `
-
-
-
${attackerName} attacks ${defenderName} with ${weaponName} !
-
Attack roll: ${attackRoll}
-
-
- Choose your defense weapon:
-
- ${attacksHTML}
-
-
-
- `
-
- // Afficher le dialogue
- const result = await foundry.applications.api.DialogV2.wait({
- window: { title: msg.isRanged ? "Ranged Defense Roll" : "Defense Roll" },
- classes: ["lethalfantasy"],
- content,
- buttons: [
- {
- action: "rangeDefense",
- type: "button",
- label: "Roll Defense",
- icon: "fa-solid fa-shield",
- callback: (event, button, dialog) => {
- const attackKey = button.form.elements.attackKey.value
- return attackKey
- },
- },
- ],
- rejectClose: false
- })
-
- // Si l'utilisateur a validé, lancer le jet de défense
- if (result) {
- // Stocker temporairement les données pour le hook preCreateChatMessage
- game.lethalFantasy = game.lethalFantasy || {}
- game.lethalFantasy.nextDefenseData = {
- attackerId,
- attackRoll,
- attackerName,
- defenderName,
- attackWeaponId,
- attackRollType,
- attackRollKey,
- attackD30result,
- attackD30message,
- attackRerollContext,
- attackNaturalRoll: msg.attackNaturalRoll,
- damageTier: msg.damageTier,
- defenderId: defender.id,
- defenderTokenId,
- isRanged: msg.isRanged
- }
-
- await defender.system.prepareMonsterRoll("monster-defense", result)
- }
- return
- }
-
- // Pour les personnages, récupérer les armes équipées
- // Si l'attaque est une attaque à distance, utiliser le dialogue de défense à distance
- if (msg.isRanged) {
- const { default: LethalFantasyRoll } = await import("./documents/roll.mjs")
- const roll = await LethalFantasyRoll.promptRangedDefense({
- actorId: defender.id,
- actorName: defender.name,
- actorImage: defender.img,
- })
- if (roll) {
- game.lethalFantasy = game.lethalFantasy || {}
- game.lethalFantasy.nextDefenseData = {
- attackerId,
- attackRoll,
- attackerName,
- defenderName,
- attackWeaponId,
- attackRollType,
- attackRollKey,
- attackD30result,
- attackD30message,
- attackRerollContext,
- damageTier: msg.damageTier,
- defenderId: defender.id,
- defenderTokenId,
- isRanged: true
- }
- await roll.toMessage({}, { messageMode: roll.options.rollMode })
- }
- return
- }
-
- // Pour les personnages, récupérer les armes équipées
- const equippedWeapons = defender.items.filter(i =>
- i.type === "weapon" && i.system.equipped === true
- )
-
- if (equippedWeapons.length === 0) {
- ui.notifications.warn("No equipped weapons for defense")
- return
- }
-
- // Créer le contenu du dialogue pour personnage
- let weaponsHTML = equippedWeapons.map(w =>
- `${w.name} `
- ).join("")
-
- const content = `
-
-
-
${attackerName} attacks ${defenderName} with ${weaponName} !
-
Attack roll: ${attackRoll}
-
-
- Choose your defense weapon:
-
- ${weaponsHTML}
-
-
-
- `
-
- // Afficher le dialogue
- const result = await foundry.applications.api.DialogV2.wait({
- window: { title: "Defense Roll" },
- classes: ["lethalfantasy"],
- content,
- buttons: [
- {
- action: "defenseRoll",
- type: "button",
- label: "Roll Defense",
- icon: "fa-solid fa-shield",
- callback: (event, button, dialog) => {
- const weaponId = button.form.elements.weaponId.value
- return weaponId
- },
- },
- ],
- rejectClose: false
- })
-
- // Si l'utilisateur a validé, lancer le jet de défense
- if (result) {
- // Stocker temporairement les données pour le hook preCreateChatMessage
- game.lethalFantasy = game.lethalFantasy || {}
- game.lethalFantasy.nextDefenseData = {
- attackerId,
- attackRoll,
- attackerName,
- defenderName,
- attackWeaponId,
- attackRollType,
- attackRollKey,
- attackD30result,
- attackD30message,
- attackRerollContext,
- attackNaturalRoll: msg.attackNaturalRoll,
- damageTier: msg.damageTier,
- defenderId: defender.id,
- defenderTokenId,
- isRanged: msg.isRanged
- }
-
- log("Storing defense data for character:", defender.id)
-
- await defender.prepareRoll("weapon-defense", result)
- }
- }
-
- /* -------------------------------------------- */
- static hasD30Reroll(d30Message) {
- return d30Message?.type === "mulligan"
- }
-
- /* -------------------------------------------- */
- /**
- * Process D30 bonus dice for attack or defense.
- * Rolls and applies bonus dice BEFORE grit/luck/shield decisions.
- * For `choice` type results (D30=20, 30), shows dialog to choose between bonus dice and special effect.
- * For `bonus_dice` type results (D30=27, 2, 3), auto-rolls the dice.
- * @param {Object|null} d30Message The D30 result object
- * @param {"attack"|"defense"} side Whether processing the attack or defense side
- * @param {number|null} naturalRoll The natural D20 roll (for special strike type detection)
- * @param {Object} actor The actor (for dice3d display)
- * @returns {Promise<{modifier: number, specialEffect: string|null, specialName: string|null}>}
- */
- static async processD30BonusDice(d30Message, side, naturalRoll = null, actor = null, canDialog = true) {
- if (!d30Message) return { modifier: 0, specialEffect: null, specialName: null }
-
- const validTargets = side === "attack" ? ["attack", "spell_attack"] : ["defense", "spell_defense"]
-
- // ── Simple bonus_dice type ── auto-roll if target matches
- if (d30Message.type === "bonus_dice") {
- if (!validTargets.includes(d30Message.target)) return { modifier: 0, specialEffect: null, specialName: null }
- const modifier = await this._rollD30BonusDie(d30Message.dice, actor, !canDialog)
- return { modifier, specialEffect: null, specialName: null }
- }
-
- // ── Choice type ── present all options to the player
- if (d30Message.type === "choice") {
- // If we can't show dialogs (wrong client), skip — the primary client
- // will communicate its choice result via socket. Auto-rolling here
- // would give a different modifier on each client, causing divergence.
- if (!canDialog) {
- return { modifier: 0, specialEffect: null, specialName: null }
- }
-
- const buttons = d30Message.choices.map(c => {
- let label
- let icon
- if (c.type === "bonus_dice") {
- label = `Roll ${c.dice.toUpperCase()} and add to ${side}`
- icon = "fa-solid fa-dice"
- } else if (c.type === "special_strike") {
- label = this._buildSpecialLabel(c, naturalRoll)
- icon = "fa-solid fa-star"
- } else if (c.type === "special_defense") {
- label = this._buildSpecialLabel(c, naturalRoll)
- icon = "fa-solid fa-shield-halved"
- } else {
- label = c.type.replace(/_/g, " ").replace(/\b\w/g, l => l.toUpperCase())
- icon = "fa-solid fa-question"
- }
- return {
- action: c.type,
- type: "button",
- label,
- icon,
- callback: () => c
- }
- })
-
- const choice = await foundry.applications.api.DialogV2.wait({
- window: { title: "D30 Special — Choose Effect" },
- classes: ["lethalfantasy"],
- content: `
-
-
D30 result: ${d30Message.description}
-
Choose how to use this result:
-
- `,
- buttons,
- rejectClose: false
- })
-
- if (!choice) return { modifier: 0, specialEffect: null, specialName: null }
-
- if (choice.type === "bonus_dice") {
- const modifier = await this._rollD30BonusDie(choice.dice, actor)
- return { modifier, specialEffect: null, specialName: null }
- }
-
- if (choice.type === "special_strike" || choice.type === "special_defense") {
- return { modifier: 0, specialEffect: "auto", specialName: this._buildSpecialName(choice, naturalRoll) }
- }
-
- // Non-standard choice (spell_calamity, etc.) — report it
- return { modifier: 0, specialEffect: "flag", specialName: choice.type }
- }
-
- // ── Combo type (bleed / internal injury) — flag for wound creation
- if (d30Message.type === "combo") {
- const hasBleed = d30Message.effects?.some(e => e.type === "bleed" || e.type === "internal_injury")
- if (hasBleed) {
- return { modifier: 0, specialEffect: "bleed", specialName: "Bleeding/Internal Injury" }
- }
- }
-
- // ── Damage multiplier type (2x/3x damage before DR)
- if (d30Message.type === "damage_multiplier") {
- return { modifier: 0, specialEffect: "damageMultiplier", specialName: `x${d30Message.multiplier} Damage`, multiplier: d30Message.multiplier }
- }
-
- // ── DR multiplier type (2x/3x DR including shield)
- if (d30Message.type === "dr_multiplier") {
- return { modifier: 0, specialEffect: "drMultiplier", specialName: `x${d30Message.multiplier} DR`, multiplier: d30Message.multiplier }
- }
-
- return { modifier: 0, specialEffect: null, specialName: null }
- }
-
- /* -------------------------------------------- */
- /**
- * Roll a D30 bonus die and show with 3D dice if available.
- * @param {string} formula Dice formula (e.g. "D6", "D12", "D20E")
- * @param {Object} actor Actor for chat message speaker
- * @returns {Promise} The roll total
- */
- static async _rollD30BonusDie(formula, actor, silent = false) {
- const cleaned = formula.replace(/NE$/i, "").replace("E", "")
- const roll = new Roll(cleaned)
- await roll.evaluate()
- if (game?.dice3d) {
- await game.dice3d.showForRoll(roll, game.user, true)
- }
- if (!silent) {
- await ChatMessage.create({
- content: `D30 bonus: rolled ${cleaned.toUpperCase()} = ${roll.total}
`,
- speaker: ChatMessage.getSpeaker({ actor })
- })
- }
- return roll.total
- }
-
- /* -------------------------------------------- */
- /**
- * Build a human-readable label for a special strike/defense choice in the D30 prompt.
- * @param {Object} specialChoice The choice object with type and options
- * @param {number|null} naturalRoll The natural D20 roll
- * @returns {string} Display label
- */
- static _buildSpecialLabel(specialChoice, naturalRoll) {
- if (specialChoice.type === "special_strike") {
- if (specialChoice.options.includes("lethal")) {
- if (naturalRoll === 20) return "Lethal Strike (auto-hit)"
- if (naturalRoll >= 16 && naturalRoll <= 19) return "Vital Strike (auto-hit)"
- return "Lethal/Vital Strike (auto-hit)"
- }
- if (specialChoice.options.includes("vicious")) return "Vicious Strike (auto-hit)"
- return "Special Strike (auto-hit)"
- }
- if (specialChoice.type === "special_defense") {
- if (specialChoice.options.includes("perfect_spell")) return "Perfect Spell Defense (auto-block)"
- if (specialChoice.options.includes("flawless")) return "Flawless Defense (auto-block)"
- if (specialChoice.options.includes("legendary")) return "Legendary Defense (auto-block)"
- if (specialChoice.options.includes("perfect")) return "Perfect Defense (auto-block)"
- return "Special Defense (auto-block)"
- }
- return "Special Effect"
- }
-
- /* -------------------------------------------- */
- /**
- * Build the special effect name based on the D30 result and natural roll.
- * @param {Object} specialChoice The choice object with type and options
- * @param {number|null} naturalRoll The natural D20 roll
- * @returns {string} The special effect name
- */
- static _buildSpecialName(specialChoice, naturalRoll) {
- if (specialChoice.type === "special_strike") {
- if (specialChoice.options.includes("lethal")) {
- if (naturalRoll === 20) return "Lethal Strike"
- if (naturalRoll >= 16 && naturalRoll <= 19) return "Vital Strike"
- return "Lethal/Vital Strike"
- }
- if (specialChoice.options.includes("vicious")) return "Vicious Strike"
- return "Special Strike"
- }
- if (specialChoice.type === "special_defense") {
- if (specialChoice.options.includes("perfect_spell")) return "Perfect Spell Defense"
- if (specialChoice.options.includes("flawless")) return "Flawless Defense"
- if (specialChoice.options.includes("legendary")) return "Legendary Defense"
- if (specialChoice.options.includes("perfect")) return "Perfect Defense"
- return "Special Defense"
- }
- return "Special Effect"
- }
-
- /* -------------------------------------------- */
- static getCombatBonusDiceChoices() {
- return ["1d4", "1d6", "1d8", "1d10", "1d12", "1d20", "1d20e"]
- }
-
- /* -------------------------------------------- */
- static getShieldReactionData(actor) {
- if (!actor) return null
- if (actor.type === "monster") {
- const formula = actor.system.combat?.shieldDefenseDice
- const damageReduction = actor.getShieldDR()
- if (!formula || damageReduction <= 0) return null
- return {
- label: game.i18n.localize("LETHALFANTASY.Label.shieldDefenseDice"),
- formula,
- damageReduction
- }
- }
-
- const equippedShields = actor.items.filter(item => item.type === "shield" && item.system.equipped)
- if (equippedShields.length === 0) return null
-
- const shield = equippedShields[0]
- return {
- label: shield.name,
- formula: shield.system.defense,
- damageReduction: actor.getShieldDR(),
- shieldId: shield.id
- }
- }
-
- /* -------------------------------------------- */
- static async promptCombatBonusDie(actorName, sideLabel, currentRoll, opposingRoll) {
- const choices = this.getCombatBonusDiceChoices()
- const optionsHtml = choices.map(choice => `${choice.toUpperCase()} `).join("")
- const content = `
-
-
-
${actorName} currently has ${currentRoll}
-
Opposing ${sideLabel} roll: ${opposingRoll}
-
-
- Choose a bonus die:
-
- ${optionsHtml}
-
-
-
- `
-
- return await foundry.applications.api.DialogV2.wait({
- window: { title: "Add Bonus Die" },
- classes: ["lethalfantasy"],
- content,
- buttons: [
- {
- action: "roll",
- type: "button",
- label: "Roll Bonus Die",
- icon: "fa-solid fa-dice",
- callback: (event, button) => {
- const sel = button.form?.elements?.bonusDie ?? button.closest("form")?.elements?.bonusDie
- return sel?.value ?? choices[0]
- }
- },
- {
- action: "cancel",
- type: "button",
- label: "Cancel",
- icon: "fa-solid fa-xmark",
- callback: () => null
- }
- ],
- rejectClose: false
- })
- }
-
- /* -------------------------------------------- */
- /**
- * Prompt the GM or player to choose an ad-hoc shield dice and DR value.
- * Used when the defender has no pre-configured shield equipment.
- * @param {string} defenderName
- * @param {number} attackRoll
- * @param {number} defenseRoll
- * @returns {Promise<{formula: string, damageReduction: number}|null>}
- */
- static async promptAdHocShield(defenderName, attackRoll, defenseRoll) {
- const choices = this.getCombatBonusDiceChoices()
- const optionsHtml = choices.map(c => `${c.toUpperCase()} `).join("")
- const content = `
-
-
-
${defenderName} uses a shield (not equipped)
-
Attack: ${attackRoll} — Current defense: ${defenseRoll}
-
-
- Shield dice:
-
- ${optionsHtml}
-
-
-
- Shield DR value:
-
-
-
- `
-
- const raw = await foundry.applications.api.DialogV2.wait({
- window: { title: "Ad-hoc Shield Roll" },
- classes: ["lethalfantasy"],
- content,
- buttons: [
- {
- action: "roll",
- type: "button",
- label: "Roll Shield",
- icon: "fa-solid fa-shield",
- callback: (event, button) => {
- const shieldDice = button.form?.elements?.shieldDice ?? button.closest("form")?.elements?.shieldDice
- const shieldDR = button.form?.elements?.shieldDR ?? button.closest("form")?.elements?.shieldDR
- return {
- formula: shieldDice?.value ?? "1d6",
- damageReduction: Number(shieldDR?.value) || 0
- }
- }
- },
- {
- action: "cancel",
- type: "button",
- label: "Cancel",
- icon: "fa-solid fa-xmark",
- callback: () => null
- }
- ],
- rejectClose: false
- })
-
- return raw ?? null
- }
-
- /* -------------------------------------------- */
- /**
- * Roll a bonus die formula, optionally showing Dice So Nice animation and posting a chat message.
- * @param {string} formula
- * @param {Actor} actor
- * @param {Function} [messageContent]
- * @returns {Promise}
- */
- static async rollBonusDie(formula, actor, messageContent) {
- const roll = new Roll(formula)
- await roll.evaluate()
- if (game?.dice3d) {
- await game.dice3d.showForRoll(roll, game.user, true)
- }
- if (messageContent) {
- await ChatMessage.create({
- content: messageContent(roll.total, formula),
- speaker: ChatMessage.getSpeaker({ actor })
- })
- }
- return roll.total
- }
-
- /* -------------------------------------------- */
- static async rerollConfiguredRoll(rerollContext = {}) {
- const RollClass = CONFIG.Dice.rolls.find(r => r.name === "LethalFantasyRoll")
- if (typeof RollClass?.prompt !== "function") {
- ui.notifications.error("Lethal Fantasy roll class not available for reroll")
- return null
- }
-
- return await RollClass.prompt({
- ...foundry.utils.duplicate(rerollContext),
- rollContext: foundry.utils.duplicate(rerollContext.rollContext || {}),
- hasTarget: false,
- target: false
- })
- }
-
- /* -------------------------------------------- */
- static async offerGritLuckBonus(defender, attackRoll, currentDefenseRoll, attackerName, defenderName) {
- let totalBonus = 0
- let keepOffering = true
-
- while (keepOffering && currentDefenseRoll + totalBonus < attackRoll) {
- const currentGrit = defender.system.grit.current
- const currentLuck = defender.system.luck.current
-
- // Si plus de points disponibles, sortir
- if (currentGrit <= 0 && currentLuck <= 0) {
- break
- }
-
- const buttons = []
-
- if (currentGrit > 0) {
- buttons.push({
- action: "grit",
- type: "button",
- label: `Spend 1 Grit (+1D6) [${currentGrit} left]`,
- icon: "fa-solid fa-fist-raised",
- callback: () => "grit"
- })
- }
-
- if (currentLuck > 0) {
- buttons.push({
- action: "luck",
- type: "button",
- label: `Spend 1 Luck (+1D6) [${currentLuck} left]`,
- icon: "fa-solid fa-clover",
- callback: () => "luck"
- })
- }
-
- buttons.push({
- action: "continue",
- type: "button",
- label: "Continue (no bonus)",
- icon: "fa-solid fa-forward",
- callback: () => "continue"
- })
-
- const content = `
-
-
-
${attackerName} rolled ${attackRoll}
-
${defenderName} currently has ${currentDefenseRoll + totalBonus}
- ${totalBonus > 0 ? `
Bonus already added: +${totalBonus}
` : ''}
-
-
You are losing! Spend Grit or Luck to add 1D6 to your defense?
-
If you intend to use a shield, you must spend Grit or Luck first — the shield roll comes after.
-
- `
-
- const choice = await foundry.applications.api.DialogV2.wait({
- window: { title: "Defend with Grit or Luck" },
- classes: ["lethalfantasy"],
- content,
- buttons,
- rejectClose: false
- })
-
- if (!choice || choice === "continue") {
- keepOffering = false
- break
- }
-
- // Lancer 1D6
- const bonusRoll = new Roll("1d6")
- await bonusRoll.evaluate()
-
- if (game?.dice3d) {
- await game.dice3d.showForRoll(bonusRoll, game.user, true)
- }
-
- totalBonus += bonusRoll.total
-
- // Déduire le point de Grit ou Luck
- if (choice === "grit") {
- await defender.update({ "system.grit.current": currentGrit - 1 })
- await ChatMessage.create({
- content: `${defenderName} spends 1 Grit and rolls ${bonusRoll.total} ! (Total defense bonus: +${totalBonus})
`,
- speaker: ChatMessage.getSpeaker({ actor: defender })
- })
- } else if (choice === "luck") {
- await defender.update({ "system.luck.current": currentLuck - 1 })
- await ChatMessage.create({
- content: `${defenderName} spends 1 Luck and rolls ${bonusRoll.total} ! (Total defense bonus: +${totalBonus})
`,
- speaker: ChatMessage.getSpeaker({ actor: defender })
- })
- }
- }
-
- return totalBonus
- }
-
- /* -------------------------------------------- */
- static async offerAttackerGritBonus(attacker, currentAttackRoll, defenseRoll, attackerName, defenderName) {
- let totalBonus = 0
- let keepOffering = true
-
- while (keepOffering && currentAttackRoll + totalBonus <= defenseRoll) {
- const currentGrit = attacker.system.grit.current
-
- // Si plus de points de Grit disponibles, sortir
- if (currentGrit <= 0) {
- break
- }
-
- const buttons = [
- {
- action: "grit",
- type: "button",
- label: `Spend 1 Grit (+1D6) [${currentGrit} left]`,
- icon: "fa-solid fa-fist-raised",
- callback: () => "grit"
- },
- {
- action: "continue",
- type: "button",
- label: "Continue (no bonus)",
- icon: "fa-solid fa-forward",
- callback: () => "continue"
- }
- ]
-
- const content = `
-
-
-
${attackerName} currently has ${currentAttackRoll + totalBonus}
-
${defenderName} rolled ${defenseRoll}
- ${totalBonus > 0 ? `
Bonus already added: +${totalBonus}
` : ''}
-
-
You are losing! Spend Grit to add 1D6 to your attack?
-
- `
-
- const choice = await foundry.applications.api.DialogV2.wait({
- window: { title: "Attack with Grit" },
- classes: ["lethalfantasy"],
- content,
- buttons,
- rejectClose: false
- })
-
- if (!choice || choice === "continue") {
- keepOffering = false
- break
- }
-
- // Lancer 1D6
- const bonusRoll = new Roll("1d6")
- await bonusRoll.evaluate()
-
- if (game?.dice3d) {
- await game.dice3d.showForRoll(bonusRoll, game.user, true)
- }
-
- totalBonus += bonusRoll.total
-
- // Déduire le point de Grit
- await attacker.update({ "system.grit.current": currentGrit - 1 })
- await ChatMessage.create({
- content: `${attackerName} spends 1 Grit and rolls ${bonusRoll.total} ! (Total attack bonus: +${totalBonus})
`,
- speaker: ChatMessage.getSpeaker({ actor: attacker })
- })
- }
-
- return totalBonus
- }
-
- /* -------------------------------------------- */
- static async compareAttackDefense(data) {
- log("compareAttackDefense called with:", data)
-
- // Compute D30 effects from the attack D30 message directly.
- // This is more reliable than depending on the caller-provided values, which are
- // computed per-client and may differ between clients due to cross-client processing order.
- const d30DamageMultiplier = data.attackD30message?.type === "damage_multiplier"
- ? data.attackD30message.multiplier
- : (data.d30DamageMultiplier || 1)
- const d30Bleed = data.attackD30message?.type === "combo"
- ? (data.attackD30message.effects?.some(e => e.type === "bleed" || e.type === "internal_injury") ? "true" : "")
- : data.attackD30message?.type === "bleed" ? "true" : (data.d30Bleed || "")
- const d30DrMultiplier = data.d30DrMultiplier || 1
-
- const outcome = data.outcome || (data.attackRoll > data.defenseRoll ? "hit" : "miss")
- const isAttackWin = outcome !== "miss"
- log("isAttackWin:", isAttackWin, "attackRoll:", data.attackRoll, "defenseRoll:", data.defenseRoll)
-
- let damageButton = ""
- if (isAttackWin && (data.attackWeaponId || data.attackRollKey)) {
- log("Creating damage button. defenderId:", data.defenderId)
- // Déterminer le type de dégâts à lancer
- if (data.attackRollType === "weapon-attack") {
- damageButton = `
-
-
- Damage
-
-
- `
- } else if (data.attackRollType === "monster-attack") {
- damageButton = `
-
-
- Damage
-
-
- `
- } else if (data.attackRollType === "spell-attack" || data.attackRollType === "miracle-attack") {
- const attacker = game.actors.get(data.attackerId)
- const spell = attacker?.items.get(data.attackWeaponId || data.attackRollKey)
- const chosenTier = data.damageTier || "standard"
- const allTiers = [
- { id: "standard", formula: spell?.system?.damageDice, label: "Standard" },
- { id: "overpowered", formula: spell?.system?.damageDiceOverpowered, label: "Overpowered" },
- { id: "overpowered2", formula: spell?.system?.damageDiceOverpowered2, label: "Overpowered 2" },
- ]
- const tiers = allTiers.filter(t => t.id === chosenTier && t.formula)
- if (tiers.length) {
- const buttons = tiers.map(t => {
- const escapedFormula = Handlebars.escapeExpression(t.formula)
- return `
-
- ${t.label} (${escapedFormula})
- `
- }).join("")
- damageButton = `${buttons}
`
- }
- }
- }
-
- const resultMessage = `
-
-
Combat Result
-
-
-
Attacker
-
-
${data.attackerName}
-
${data.attackRoll}
-
-
-
VS
-
-
Defender
-
-
${data.defenderName}
-
${data.defenseRoll}
-
-
-
-
- ${outcome === "shielded-hit"
- ? ` ${data.defenderName} has blocked with shield — apply armor DR + shield DR ${data.shieldDamageReduction || 0} .`
- : isAttackWin
- ? ` ${data.attackerName} hits ${data.defenderName} !`
- : ` ${data.defenderName} avoided the attack!`
- }
-
- ${damageButton}
-
- `
-
- log("Creating combat result message...")
- await ChatMessage.create({
- content: resultMessage,
- speaker: { alias: "Combat System" }
- })
- log("Combat result message created!")
- }
-
- static registerHandlebarsHelpers() {
-
- Handlebars.registerHelper('isNull', function (val) {
- return val == null;
- });
- Handlebars.registerHelper('match', function (val, search) {
- if (val && search) {
- return val?.match(search);
- }
- return false
- });
-
- Handlebars.registerHelper('exists', function (val) {
- return val != null && val !== undefined;
- });
-
- Handlebars.registerHelper('isEmpty', function (list) {
- if (list) return list.length === 0;
- else return false;
- });
-
- Handlebars.registerHelper('notEmpty', function (list) {
- return list.length > 0;
- });
-
- Handlebars.registerHelper('isNegativeOrNull', function (val) {
- return val <= 0;
- });
-
- Handlebars.registerHelper('isNegative', function (val) {
- return val < 0;
- });
-
- Handlebars.registerHelper('isPositive', function (val) {
- return val > 0;
- });
-
- Handlebars.registerHelper('equals', function (val1, val2) {
- return val1 === val2;
- });
-
- Handlebars.registerHelper('neq', function (val1, val2) {
- return val1 !== val2;
- });
-
- Handlebars.registerHelper('gt', function (val1, val2) {
- return val1 > val2;
- })
-
- Handlebars.registerHelper('lt', function (val1, val2) {
- return val1 < val2;
- })
-
- Handlebars.registerHelper('gte', function (val1, val2) {
- return val1 >= val2;
- })
-
- Handlebars.registerHelper('lte', function (val1, val2) {
- return val1 <= val2;
- })
- Handlebars.registerHelper('and', function (val1, val2) {
- return val1 && val2;
- })
- Handlebars.registerHelper('or', function (val1, val2) {
- return val1 || val2;
- })
-
- Handlebars.registerHelper('or3', function (val1, val2, val3) {
- return val1 || val2 || val3;
- })
-
- Handlebars.registerHelper('for', function (from, to, incr, block) {
- let accum = '';
- for (let i = from; i < to; i += incr)
- accum += block.fn(i);
- return accum;
- })
-
- Handlebars.registerHelper('not', function (cond) {
- return !cond;
- })
- Handlebars.registerHelper('count', function (list) {
- return list.length;
- })
- Handlebars.registerHelper('countKeys', function (obj) {
- return Object.keys(obj).length;
- })
-
- Handlebars.registerHelper('isEnabled', function (configKey) {
- return game.settings.get("bol", configKey);
- })
- Handlebars.registerHelper('split', function (str, separator, keep) {
- return str.split(separator)[keep];
- })
-
- // If you need to add Handlebars helpers, here are a few useful examples:
- Handlebars.registerHelper('concat', function () {
- let outStr = '';
- for (let arg in arguments) {
- if (typeof arguments[arg] != 'object') {
- outStr += arguments[arg];
- }
- }
- return outStr;
- })
-
- Handlebars.registerHelper('add', function (a, b) {
- return parseInt(a) + parseInt(b);
- });
- Handlebars.registerHelper('mul', function (a, b) {
- return parseInt(a) * parseInt(b);
- })
- Handlebars.registerHelper('sub', function (a, b) {
- return parseInt(a) - parseInt(b);
- })
- Handlebars.registerHelper('abbrev2', function (a) {
- return a.substring(0, 2);
- })
- Handlebars.registerHelper('abbrev3', function (a) {
- return a.substring(0, 3);
- })
- Handlebars.registerHelper('valueAtIndex', function (arr, idx) {
- return arr[idx];
- })
- Handlebars.registerHelper('includesKey', function (items, type, key) {
- return items.filter(i => i.type === type).map(i => i.system.key).includes(key);
- })
- Handlebars.registerHelper('includes', function (array, val) {
- return array.includes(val);
- })
- Handlebars.registerHelper('eval', function (expr) {
- return eval(expr);
- })
- Handlebars.registerHelper('isOwnerOrGM', function (actor) {
- log("Testing actor", actor.isOwner, game.userId)
- return actor.isOwner || game.isGM;
- })
- Handlebars.registerHelper('upperCase', function (text) {
- if (typeof text !== 'string') return text
- return text.toUpperCase()
- })
- Handlebars.registerHelper('upperFirst', function (text) {
- if (typeof text !== 'string') return text
- return text.charAt(0).toUpperCase() + text.slice(1)
- })
- Handlebars.registerHelper('upperFirstOnly', function (text) {
- if (typeof text !== 'string') return text
- return text.charAt(0).toUpperCase()
- })
-
- // Handle v12 removal of this helper
- Handlebars.registerHelper('select', function (selected, options) {
- const escapedValue = RegExp.escape(Handlebars.escapeExpression(selected));
- const rgx = new RegExp(' value=[\"\']' + escapedValue + '[\"\']');
- const html = options.fn(this);
- return html.replace(rgx, "$& selected");
- });
-
- }
-
- static getLethargyDice(level) {
- for (let s of SYSTEM.SPELL_LETHARGY_DICE) {
- if (Number(level) <= s.maxLevel) {
- return s.dice
- }
- }
- }
-
- /* -------------------------------------------- */
- static async applyDamage(message, event) {
- // Récupérer les données du message
- let combatantId = event.currentTarget.dataset.combatantId
- if (!combatantId) {
- ui.notifications.error("No combatant selected")
- return
- }
-
- // Try to find the target: first as a combat combatant, then as a scene token
- let targetActor = null
- if (game.combat) {
- const combatant = game.combat.combatants.get(combatantId)
- if (combatant) {
- targetActor = combatant.token?.actor || game.actors.get(combatant.actorId)
- }
- }
- if (!targetActor) {
- // Fall back to scene token lookup (non-combat tokens use tokenId as their combatantId)
- const token = canvas.tokens?.placeables?.find(t => t.id === combatantId)
- targetActor = token?.actor
- }
- if (!targetActor) {
- ui.notifications.error("Target actor not found")
- return
- }
-
- // Récupérer les données de dégâts du message
- // Use options.rollTotal (includes weapon modifier bonus) rather than roll.total (dice formula only)
- let damageTotal = message.rolls[0]?.options?.rollTotal ?? message.rolls[0]?.total ?? 0
- let weaponName = message.rolls[0]?.options?.rollName || "Unknown Weapon"
-
- // Calculer les DR
- let armorDR = targetActor.computeDamageReduction() || 0
- let shieldDR = targetActor.getShieldDR() || 0
- let totalDR = armorDR + shieldDR
-
- // Créer le dialogue
- const content = await foundry.applications.handlebars.renderTemplate(
- "systems/fvtt-lethal-fantasy/templates/apply-damage-dialog.hbs",
- {
- targetName: targetActor.name,
- weaponName: weaponName,
- damageTotal: damageTotal,
- armorDR: armorDR,
- shieldDR: shieldDR,
- totalDR: totalDR,
- damageNoDR: damageTotal,
- damageWithArmor: Math.max(0, damageTotal - armorDR),
- damageWithAll: Math.max(0, damageTotal - totalDR)
- }
- )
-
- const result = await foundry.applications.api.DialogV2.wait({
- window: { title: "Apply Damage" },
- classes: ["lethalfantasy"],
- position: { width: 280 },
- content,
- buttons: [
- {
- action: "noDR",
- type: "button",
- label: "No DR",
- callback: () => ({ drType: "none", damage: damageTotal })
- },
- {
- action: "armorDR",
- type: "button",
- label: "With Armor DR",
- callback: () => ({ drType: "armor", damage: Math.max(0, damageTotal - armorDR) })
- },
- {
- action: "allDR",
- type: "button",
- label: "With Armor + Shield DR",
- callback: () => ({ drType: "all", damage: Math.max(0, damageTotal - totalDR) })
- },
- {
- action: "cancel",
- type: "button",
- label: "Cancel",
- callback: () => null
- }
- ],
- rejectClose: false
- })
-
- if (result && result.damage !== undefined) {
- await targetActor.applyDamage(-result.damage)
-
- // Message de confirmation
- let drText = ""
- if (result.drType === "armor") {
- drText = `Armor DR: ${armorDR}`
- } else if (result.drType === "all") {
- drText = `Total DR: ${totalDR}`
- }
-
- const messageContent = await foundry.applications.handlebars.renderTemplate(
- "systems/fvtt-lethal-fantasy/templates/damage-applied-message.hbs",
- {
- targetName: targetActor.name,
- damage: result.damage,
- drText: drText,
- weaponName: weaponName
- }
- )
-
- await ChatMessage.create({
- user: game.user.id,
- speaker: { alias: targetActor.name },
- mode: "gm",
- content: messageContent
- })
- }
- }
-
+ static loadCompendiumData = loadCompendiumData
+ static loadCompendium = loadCompendium
+ static pushCombatOptions = pushCombatOptions
+ static setHookListeners = setHookListeners
+ static registerHandlebarsHelpers = registerHandlebarsHelpers
+ static getLethargyDice = getLethargyDice
+ static hasD30Reroll = hasD30Reroll
+ static processD30BonusDice = processD30BonusDice
+ static _rollD30BonusDie = _rollD30BonusDie
+ static _buildSpecialLabel = _buildSpecialLabel
+ static _buildSpecialName = _buildSpecialName
+ static handleSocketEvent = handleSocketEvent
+ static handleAttackerGritOffer = handleAttackerGritOffer
+ static handleAttackBoosted = handleAttackBoosted
+ static showDefenseRequest = showDefenseRequest
+ static compareAttackDefense = compareAttackDefense
+ static applyDamage = applyDamage
+ static offerAttackerGritBonus = offerAttackerGritBonus
+ static getCombatBonusDiceChoices = getCombatBonusDiceChoices
+ static getShieldReactionData = getShieldReactionData
+ static promptCombatBonusDie = promptCombatBonusDie
+ static promptAdHocShield = promptAdHocShield
+ static rollBonusDie = rollBonusDie
+ static rerollConfiguredRoll = rerollConfiguredRoll
}
diff --git a/module/utils/combat.mjs b/module/utils/combat.mjs
new file mode 100644
index 0000000..e5861dc
--- /dev/null
+++ b/module/utils/combat.mjs
@@ -0,0 +1,1000 @@
+import { SYSTEM } from "../config/system.mjs"
+import { log } from "./helpers.mjs"
+import { processD30BonusDice } from "./d30.mjs"
+
+export async function handleSocketEvent(msg = {}) {
+ log(`handleSocketEvent !`, msg)
+ let actor
+ switch (msg.type) {
+ case "applyDamage":
+ if (game.user.isGM) {
+ // Prefer the specific token actor (correct for unlinked monsters); fall back to world actor.
+ actor = msg.tokenId
+ ? canvas.tokens?.placeables?.find(t => t.id === msg.tokenId)?.actor
+ : (game.combat?.combatants?.find(c => c.actorId === msg.actorId)?.actor
+ ?? game.actors.get(msg.actorId))
+ if (actor) await actor.applyDamage(msg.damage)
+ }
+ break
+ case "rollInitiative":
+ if (msg.userId && msg.userId !== game.user.id) break
+ actor = game.actors.get(msg.actorId)
+ await actor.system.rollInitiative(msg.combatId, msg.combatantId)
+ break
+ case "rollProgressionDice":
+ if (msg.userId && msg.userId !== game.user.id) break
+ actor = game.actors.get(msg.actorId)
+ await actor.system.rollProgressionDice(msg.combatId, msg.combatantId, msg.rollProgressionCount)
+ break
+ case "requestDefense":
+ // Vérifier si le message est destiné à cet utilisateur
+ if (msg.userId === game.user.id) {
+ showDefenseRequest(msg)
+ }
+ break
+ case "offerAttackerGrit":
+ // Vérifier si le message est destiné à cet utilisateur
+ if (msg.userId === game.user.id) {
+ handleAttackerGritOffer(msg)
+ }
+ break
+ case "applyBleeding":
+ if (game.user.isGM) {
+ actor = msg.tokenId
+ ? canvas.tokens?.placeables?.find(t => t.id === msg.tokenId)?.actor
+ : game.actors.get(msg.actorId)
+ if (actor && actor.system.hp?.wounds && msg.damage > 0) {
+ const wounds = foundry.utils.duplicate(actor.system.hp.wounds)
+ const slot = wounds.findIndex(w => !w.value && !w.duration)
+ if (slot !== -1) {
+ wounds[slot] = { value: msg.damage, duration: msg.damage, description: "Bleeding" }
+ await actor.update({ "system.hp.wounds": wounds })
+ }
+ }
+ }
+ break
+ case "attackBoosted":
+ if (msg.userId === game.user.id) {
+ handleAttackBoosted(msg)
+ }
+ break
+ }
+}
+
+export async function handleAttackerGritOffer(msg) {
+ const { attackerId, attackRoll, defenseRoll, attackerName, defenderName, attackWeaponId, attackRollType, attackRollKey, defenderId } = msg
+
+ const attacker = game.actors.get(attackerId)
+ if (!attacker) {
+ console.warn("Attacker not found:", attackerId)
+ return
+ }
+
+ const attackBonus = await offerAttackerGritBonus(
+ attacker,
+ attackRoll,
+ defenseRoll,
+ attackerName,
+ defenderName
+ )
+
+ const attackRollFinal = attackRoll + attackBonus
+
+ // Maintenant créer le message de comparaison
+ await compareAttackDefense({
+ attackerName,
+ attackerId,
+ attackRoll: attackRollFinal,
+ attackWeaponId,
+ attackRollType,
+ attackRollKey,
+ defenderName,
+ defenderId,
+ defenseRoll
+ })
+}
+
+export async function handleAttackBoosted(msg) {
+ const {
+ attackerName, attackerId, defenderName, defenderId, defenderTokenId,
+ attackerHandledBonus, attackRollFinal, defenseRoll, attackWeaponId, attackRollType, attackRollKey,
+ shieldDamageReduction: initialShieldDR,
+ d30Bleed, d30DamageMultiplier, d30DrMultiplier,
+ damageTier, attackD30message, defenseD30message,
+ hasShield, shieldLabel, shieldFormula, shieldDr, canAdHocShield
+ } = msg
+
+ const defender = game.actors.get(defenderId)
+ if (!defender) return
+
+ let updatedDefenseRoll = defenseRoll
+ let shieldBlocked = false
+ let shieldReaction = null
+ let canShieldReact = hasShield
+ let canAdHoc = canAdHocShield
+
+ // ── D30 bonus dice (defense) — resolved before grit/luck/shield ───────
+ let defenseDrMultiplier = null
+ if (defenseD30message && defender) {
+ const d30Result = await processD30BonusDice(defenseD30message, "defense", null, defender, true)
+ if (d30Result.modifier) {
+ updatedDefenseRoll += d30Result.modifier
+ if (d30Result.modifier > 0) {
+ const msg = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"d30Bonus", actorName:defenderName, value:d30Result.modifier, side:"defense"})
+ await ChatMessage.create({content: msg, speaker: ChatMessage.getSpeaker({actor: defender})})
+ }
+ }
+ if (d30Result.specialEffect === "auto") {
+ updatedDefenseRoll = attackRollFinal + 1
+ const msg = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"d30Auto", actorName:defenderName, specialName:d30Result.specialName || "Special Defense", side:"defense"})
+ await ChatMessage.create({content: msg, speaker: ChatMessage.getSpeaker({actor: defender})})
+ }
+ if (d30Result.specialEffect === "flag") {
+ const msg = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"d30Flag", actorName:defenderName, specialName:d30Result.specialName || "Special Effect"})
+ await ChatMessage.create({content: msg, speaker: ChatMessage.getSpeaker({actor: defender})})
+ }
+ if (d30Result.specialEffect === "drMultiplier") {
+ defenseDrMultiplier = d30Result.multiplier
+ const msg = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"d30DRMultiplier", actorName:defenderName, value:d30Result.multiplier})
+ await ChatMessage.create({content: msg, speaker: ChatMessage.getSpeaker({actor: defender})})
+ }
+ }
+
+ // Show the defense reaction dialog — while-loop for multiple reactions
+ if (defender) {
+ while (updatedDefenseRoll < attackRollFinal) {
+ const currentGrit = Number(defender.system?.grit?.current) || 0
+ const currentLuck = Number(defender.system?.luck?.current) || 0
+ const buttons = []
+
+ if (currentGrit > 0) {
+ buttons.push({
+ action: "grit",
+ type: "button",
+ label: `Spend 1 Grit (+1D6) [${currentGrit} left]`,
+ icon: "fa-solid fa-fist-raised",
+ callback: () => "grit"
+ })
+ }
+
+ if (currentLuck > 0) {
+ buttons.push({
+ action: "luck",
+ type: "button",
+ label: `Spend 1 Luck (+1D6) [${currentLuck} left]`,
+ icon: "fa-solid fa-clover",
+ callback: () => "luck"
+ })
+ }
+
+ buttons.push({
+ action: "bonusDie",
+ type: "button",
+ label: "Add bonus die",
+ icon: "fa-solid fa-dice",
+ callback: () => "bonusDie"
+ })
+
+ if (canShieldReact) {
+ buttons.push({
+ action: "shieldReact",
+ type: "button",
+ label: `Roll shield (${shieldLabel})`,
+ icon: "fa-solid fa-shield",
+ callback: () => "shieldReact"
+ })
+ } else if (canAdHoc) {
+ buttons.push({
+ action: "adHocShield",
+ type: "button",
+ label: "Roll ad-hoc shield (choose dice + DR)",
+ icon: "fa-solid fa-shield-halved",
+ callback: () => "adHocShield"
+ })
+ }
+
+ buttons.push({
+ action: "continue",
+ type: "button",
+ label: "Continue (no defense bonus)",
+ icon: "fa-solid fa-forward",
+ callback: () => "continue"
+ })
+
+ const choice = await foundry.applications.api.DialogV2.wait({
+ window: { title: "Defense reactions — attack boosted" },
+ classes: ["lethalfantasy"],
+ content: await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/defense-reaction.hbs", {
+ attackerName,
+ attackRoll: attackRollFinal,
+ attackStatus: "boosted attack to",
+ defenderName,
+ defenseRoll: updatedDefenseRoll,
+ defenseStatus: "currently has",
+ d30message: null,
+ offerText: "The attack was boosted! Choose how to improve the defense."
+ }),
+ buttons,
+ rejectClose: false
+ })
+
+ if (!choice || choice === "continue") break
+
+ if (choice === "grit") {
+ const bonusRoll = await rollBonusDie("1d6", defender)
+ updatedDefenseRoll += bonusRoll
+ await defender.update({ "system.grit.current": currentGrit - 1 })
+ const gritRmContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"grit", actorName:defenderName, resource:"Grit", value:bonusRoll, side:"defense"})
+ await ChatMessage.create({content: gritRmContent, speaker: ChatMessage.getSpeaker({actor: defender})})
+ } else if (choice === "luck") {
+ const bonusRoll = await rollBonusDie("1d6", defender)
+ updatedDefenseRoll += bonusRoll
+ await defender.update({ "system.luck.current": currentLuck - 1 })
+ const luckRmContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"luck", actorName:defenderName, resource:"Luck", value:bonusRoll, side:"defense"})
+ await ChatMessage.create({content: luckRmContent, speaker: ChatMessage.getSpeaker({actor: defender})})
+ } else if (choice === "bonusDie") {
+ const bonusDie = await promptCombatBonusDie(defenderName, "attack", updatedDefenseRoll, attackRollFinal)
+ if (bonusDie) {
+ const bonusRoll = await rollBonusDie(bonusDie, defender)
+ updatedDefenseRoll += bonusRoll
+ const bonusRmContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"bonusDie", actorName:defenderName, formula:bonusDie.toUpperCase(), value:bonusRoll, side:"defense"})
+ await ChatMessage.create({content: bonusRmContent, speaker: ChatMessage.getSpeaker({actor: defender})})
+ }
+ } else if (choice === "shieldReact" && canShieldReact) {
+ const shieldBonus = await rollBonusDie(shieldFormula, defender)
+ const newDefenseTotal = updatedDefenseRoll + shieldBonus
+ updatedDefenseRoll = newDefenseTotal
+ canShieldReact = false
+ if (newDefenseTotal >= attackRollFinal) {
+ shieldBlocked = true
+ shieldReaction = { damageReduction: shieldDr, label: shieldLabel, bonus: shieldBonus }
+ const shieldBlockContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"shieldBlock", actorName:defenderName, shieldLabel, shieldBonus, newTotal:newDefenseTotal, opposingRoll:attackRollFinal, shieldDR:shieldDr})
+ await ChatMessage.create({
+ content: shieldBlockContent,
+ speaker: ChatMessage.getSpeaker({ actor: defender })
+ })
+ } else {
+ const shieldFailContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"shieldFail", actorName:defenderName, shieldLabel, shieldBonus, newTotal:newDefenseTotal, opposingRoll:attackRollFinal})
+ await ChatMessage.create({
+ content: shieldFailContent,
+ speaker: ChatMessage.getSpeaker({ actor: defender })
+ })
+ }
+ } else if (choice === "adHocShield" && canAdHoc) {
+ const adHoc = await promptAdHocShield(defenderName, attackRollFinal, updatedDefenseRoll)
+ if (adHoc) {
+ const shieldBonus = await rollBonusDie(adHoc.formula, defender)
+ const newDefenseTotal = updatedDefenseRoll + shieldBonus
+ updatedDefenseRoll = newDefenseTotal
+ canShieldReact = false
+ canAdHoc = false
+ if (newDefenseTotal >= attackRollFinal) {
+ shieldBlocked = true
+ shieldReaction = { damageReduction: adHoc.damageReduction, label: `${adHoc.formula.toUpperCase()} shield`, bonus: shieldBonus }
+ const adHocShieldBlockContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"shieldBlock", actorName:defenderName, shieldLabel: `${adHoc.formula.toUpperCase()} shield`, shieldBonus, newTotal:newDefenseTotal, opposingRoll:attackRollFinal, shieldDR:adHoc.damageReduction})
+ await ChatMessage.create({
+ content: adHocShieldBlockContent,
+ speaker: ChatMessage.getSpeaker({ actor: defender })
+ })
+ } else {
+ const adHocShieldFailContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"shieldFail", actorName:defenderName, shieldLabel: `${adHoc.formula.toUpperCase()} shield`, shieldBonus, newTotal:newDefenseTotal, opposingRoll:attackRollFinal})
+ await ChatMessage.create({
+ content: adHocShieldFailContent,
+ speaker: ChatMessage.getSpeaker({ actor: defender })
+ })
+ }
+ }
+ }
+ }
+ }
+
+ const finalShieldDR = shieldBlocked ? (shieldReaction?.damageReduction || 0) : 0
+ const outcome = shieldBlocked ? "shielded-hit" : (updatedDefenseRoll >= attackRollFinal ? "miss" : "hit")
+
+ await compareAttackDefense({
+ attackerName,
+ attackerId,
+ attackRoll: attackRollFinal,
+ attackWeaponId,
+ attackRollType,
+ attackRollKey,
+ defenderName,
+ defenderId,
+ defenderTokenId,
+ defenseRoll: updatedDefenseRoll,
+ outcome,
+ shieldDamageReduction: finalShieldDR,
+ d30Bleed: d30Bleed || "",
+ d30DamageMultiplier: d30DamageMultiplier || 1,
+ d30DrMultiplier: (d30DrMultiplier != null && d30DrMultiplier !== 1) ? d30DrMultiplier : (defenseDrMultiplier ?? d30DrMultiplier ?? 1),
+ damageTier: damageTier || "standard",
+ attackD30message
+ })
+}
+
+export async function showDefenseRequest(msg) {
+ const attackerName = msg.attackerName
+ const attackerId = msg.attackerId
+ const defenderName = msg.defenderName
+ const weaponName = msg.weaponName || "attack"
+ const attackRoll = msg.attackRoll
+ const attackWeaponId = msg.attackWeaponId
+ const attackRollType = msg.attackRollType
+ const attackRollKey = msg.attackRollKey
+ const attackD30result = msg.attackD30result
+ const attackD30message = msg.attackD30message
+ const attackRerollContext = msg.attackRerollContext
+ const combatantId = msg.combatantId
+ const tokenId = msg.tokenId
+
+ // Récupérer le défenseur - essayer d'abord depuis le combat, puis depuis le token
+ let defender = null
+
+ if (game.combat && combatantId) {
+ const combatant = game.combat.combatants.get(combatantId)
+ if (combatant) {
+ defender = combatant.actor
+ }
+ }
+
+ // Si pas trouvé dans le combat, chercher le token directement
+ if (!defender && tokenId) {
+ const token = canvas.tokens.get(tokenId)
+ if (token) {
+ defender = token.actor
+ }
+ }
+
+ if (!defender) {
+ ui.notifications.error("Defender actor not found")
+ return
+ }
+
+ // Resolve the specific token ID now while we still have combatant/token context.
+ // This is passed through to the damage roll so the GM-side socket handler can find the
+ // correct synthetic actor for unlinked tokens (avoids wrong-instance damage with multiple
+ // unlinked copies of the same monster).
+ const defenderTokenId = (() => {
+ if (game.combat && combatantId) {
+ const cbt = game.combat.combatants.get(combatantId)
+ if (cbt?.token?.id) return cbt.token.id
+ }
+ return tokenId ?? canvas.tokens?.placeables?.find(t => t.actor?.id === defender.id)?.id ?? null
+ })()
+
+ const isMonster = defender.type === "monster"
+
+ log(`[LF] showDefenseRequest | attackRollType=${attackRollType} isMonster=${isMonster} defender=${defender?.name}`)
+
+ // Spell/miracle attacks use saving throws instead of weapon defense
+ const isSpellAttack = attackRollType === "spell-attack" || attackRollType === "miracle-attack"
+ if (isSpellAttack) {
+ const savesConfig = isMonster ? SYSTEM.MONSTER_SAVES : SYSTEM.SAVES
+ const combatSaves = ["will", "dodge", "toughness"]
+ const savesList = Object.values(savesConfig)
+ .filter(s => combatSaves.includes(s.id))
+ .map(s => ({id: s.id, label: game.i18n.localize(s.label)}))
+
+ const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/defense-request-save.hbs", {
+ attackerName, defenderName, weaponName, attackRoll, saves: savesList
+ })
+
+ const result = await foundry.applications.api.DialogV2.wait({
+ window: { title: "Saving Throw vs Spell" },
+ classes: ["lethalfantasy"],
+ content,
+ buttons: [
+ {
+ action: "rollSave",
+ type: "button",
+ label: "Roll Save",
+ icon: "fa-solid fa-person-running",
+ callback: (event, button) => button.form.elements.saveKey.value,
+ },
+ ],
+ rejectClose: false
+ })
+
+ if (result) {
+ game.lethalFantasy = game.lethalFantasy || {}
+ game.lethalFantasy.spellDefense = true // pré-cocher "Save against spell" dans le dialog
+ game.lethalFantasy.nextDefenseData = {
+ attackerId,
+ attackRoll,
+ attackerName,
+ defenderName,
+ attackWeaponId,
+ attackRollType,
+ attackRollKey,
+ attackD30result,
+ attackD30message,
+ attackRerollContext,
+ attackNaturalRoll: msg.attackNaturalRoll,
+ damageTier: msg.damageTier,
+ defenderId: defender.id,
+ defenderTokenId
+ }
+ if (isMonster) {
+ await defender.system.prepareMonsterRoll("save", result)
+ } else {
+ await defender.prepareRoll("save", result)
+ }
+ }
+ return
+ }
+
+ // Pour les monstres, récupérer les attaques activées
+ if (isMonster) {
+ const attacksSet = defender.system.attackMode === "ranged" ? defender.system.rangedAttacks : defender.system.attacks
+ const enabledAttacks = Object.entries(attacksSet).filter(([key, attack]) => attack.enabled)
+
+ if (enabledAttacks.length === 0) {
+ ui.notifications.warn("No enabled attacks available for defense")
+ return
+ }
+
+ // Créer le contenu du dialogue pour monstre
+ const attacksList = enabledAttacks.map(([key, attack]) => ({key, name: attack.name}))
+
+ const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/defense-request-monster.hbs", {
+ attackerName, defenderName, weaponName, attackRoll, attacks: attacksList
+ })
+
+ // Afficher le dialogue
+ const result = await foundry.applications.api.DialogV2.wait({
+ window: { title: msg.isRanged ? "Ranged Defense Roll" : "Defense Roll" },
+ classes: ["lethalfantasy"],
+ content,
+ buttons: [
+ {
+ action: "rangeDefense",
+ type: "button",
+ label: "Roll Defense",
+ icon: "fa-solid fa-shield",
+ callback: (event, button, dialog) => {
+ const attackKey = button.form.elements.attackKey.value
+ return attackKey
+ },
+ },
+ ],
+ rejectClose: false
+ })
+
+ // Si l'utilisateur a validé, lancer le jet de défense
+ if (result) {
+ // Stocker temporairement les données pour le hook preCreateChatMessage
+ game.lethalFantasy = game.lethalFantasy || {}
+ game.lethalFantasy.nextDefenseData = {
+ attackerId,
+ attackRoll,
+ attackerName,
+ defenderName,
+ attackWeaponId,
+ attackRollType,
+ attackRollKey,
+ attackD30result,
+ attackD30message,
+ attackRerollContext,
+ attackNaturalRoll: msg.attackNaturalRoll,
+ damageTier: msg.damageTier,
+ defenderId: defender.id,
+ defenderTokenId,
+ isRanged: msg.isRanged
+ }
+
+ await defender.system.prepareMonsterRoll("monster-defense", result)
+ }
+ return
+ }
+
+ // Pour les personnages, récupérer les armes équipées
+ // Si l'attaque est une attaque à distance, utiliser le dialogue de défense à distance
+ if (msg.isRanged) {
+ const { default: LethalFantasyRoll } = await import("../documents/roll.mjs")
+ const roll = await LethalFantasyRoll.promptRangedDefense({
+ actorId: defender.id,
+ actorName: defender.name,
+ actorImage: defender.img,
+ })
+ if (roll) {
+ game.lethalFantasy = game.lethalFantasy || {}
+ game.lethalFantasy.nextDefenseData = {
+ attackerId,
+ attackRoll,
+ attackerName,
+ defenderName,
+ attackWeaponId,
+ attackRollType,
+ attackRollKey,
+ attackD30result,
+ attackD30message,
+ attackRerollContext,
+ damageTier: msg.damageTier,
+ defenderId: defender.id,
+ defenderTokenId,
+ isRanged: true
+ }
+ await roll.toMessage({}, { messageMode: roll.options.rollMode })
+ }
+ return
+ }
+
+ // Pour les personnages, récupérer les armes équipées
+ const equippedWeapons = defender.items.filter(i =>
+ i.type === "weapon" && i.system.equipped === true
+ )
+
+ if (equippedWeapons.length === 0) {
+ ui.notifications.warn("No equipped weapons for defense")
+ return
+ }
+
+ // Créer le contenu du dialogue pour personnage
+ const weaponsList = equippedWeapons.map(w => ({id: w.id, name: w.name}))
+
+ const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/defense-request-character.hbs", {
+ attackerName, defenderName, weaponName, attackRoll, weapons: weaponsList
+ })
+
+ // Afficher le dialogue
+ const result = await foundry.applications.api.DialogV2.wait({
+ window: { title: "Defense Roll" },
+ classes: ["lethalfantasy"],
+ content,
+ buttons: [
+ {
+ action: "defenseRoll",
+ type: "button",
+ label: "Roll Defense",
+ icon: "fa-solid fa-shield",
+ callback: (event, button, dialog) => {
+ const weaponId = button.form.elements.weaponId.value
+ return weaponId
+ },
+ },
+ ],
+ rejectClose: false
+ })
+
+ // Si l'utilisateur a validé, lancer le jet de défense
+ if (result) {
+ // Stocker temporairement les données pour le hook preCreateChatMessage
+ game.lethalFantasy = game.lethalFantasy || {}
+ game.lethalFantasy.nextDefenseData = {
+ attackerId,
+ attackRoll,
+ attackerName,
+ defenderName,
+ attackWeaponId,
+ attackRollType,
+ attackRollKey,
+ attackD30result,
+ attackD30message,
+ attackRerollContext,
+ attackNaturalRoll: msg.attackNaturalRoll,
+ damageTier: msg.damageTier,
+ defenderId: defender.id,
+ defenderTokenId,
+ isRanged: msg.isRanged
+ }
+
+ log("Storing defense data for character:", defender.id)
+
+ await defender.prepareRoll("weapon-defense", result)
+ }
+}
+
+export function getCombatBonusDiceChoices() {
+ return ["1d4", "1d6", "1d8", "1d10", "1d12", "1d20", "1d20e"]
+}
+
+export function getShieldReactionData(actor) {
+ if (!actor) return null
+ if (actor.type === "monster") {
+ const formula = actor.system.combat?.shieldDefenseDice
+ const damageReduction = actor.getShieldDR()
+ if (!formula || damageReduction <= 0) return null
+ return {
+ label: game.i18n.localize("LETHALFANTASY.Label.shieldDefenseDice"),
+ formula,
+ damageReduction
+ }
+ }
+
+ const equippedShields = actor.items.filter(item => item.type === "shield" && item.system.equipped)
+ if (equippedShields.length === 0) return null
+
+ const shield = equippedShields[0]
+ return {
+ label: shield.name,
+ formula: shield.system.defense,
+ damageReduction: actor.getShieldDR(),
+ shieldId: shield.id
+ }
+}
+
+export async function promptCombatBonusDie(actorName, sideLabel, currentRoll, opposingRoll) {
+ const choicesList = getCombatBonusDiceChoices()
+ const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/bonus-die-select.hbs", {
+ actorName, currentRoll, opposingRoll, sideLabel, choices: choicesList
+ })
+
+ return await foundry.applications.api.DialogV2.wait({
+ window: { title: "Add Bonus Die" },
+ classes: ["lethalfantasy"],
+ content,
+ buttons: [
+ {
+ action: "roll",
+ type: "button",
+ label: "Roll Bonus Die",
+ icon: "fa-solid fa-dice",
+ callback: (event, button) => {
+ const sel = button.form?.elements?.bonusDie ?? button.closest("form")?.elements?.bonusDie
+ return sel?.value ?? choicesList[0]
+ }
+ },
+ {
+ action: "cancel",
+ type: "button",
+ label: "Cancel",
+ icon: "fa-solid fa-xmark",
+ callback: () => null
+ }
+ ],
+ rejectClose: false
+ })
+}
+
+/**
+ * Prompt the GM or player to choose an ad-hoc shield dice and DR value.
+ * Used when the defender has no pre-configured shield equipment.
+ * @param {string} defenderName
+ * @param {number} attackRoll
+ * @param {number} defenseRoll
+ * @returns {Promise<{formula: string, damageReduction: number}|null>}
+ */
+export async function promptAdHocShield(defenderName, attackRoll, defenseRoll) {
+ const choicesList = getCombatBonusDiceChoices()
+ const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/ad-hoc-shield.hbs", {
+ defenderName, attackRoll, defenseRoll, choices: choicesList
+ })
+
+ const raw = await foundry.applications.api.DialogV2.wait({
+ window: { title: "Ad-hoc Shield Roll" },
+ classes: ["lethalfantasy"],
+ content,
+ buttons: [
+ {
+ action: "roll",
+ type: "button",
+ label: "Roll Shield",
+ icon: "fa-solid fa-shield",
+ callback: (event, button) => {
+ const shieldDice = button.form?.elements?.shieldDice ?? button.closest("form")?.elements?.shieldDice
+ const shieldDR = button.form?.elements?.shieldDR ?? button.closest("form")?.elements?.shieldDR
+ return {
+ formula: shieldDice?.value ?? "1d6",
+ damageReduction: Number(shieldDR?.value) || 0
+ }
+ }
+ },
+ {
+ action: "cancel",
+ type: "button",
+ label: "Cancel",
+ icon: "fa-solid fa-xmark",
+ callback: () => null
+ }
+ ],
+ rejectClose: false
+ })
+
+ return raw ?? null
+}
+
+/**
+ * Roll a bonus die formula, optionally showing Dice So Nice animation.
+ * @param {string} formula
+ * @param {Actor} actor
+ * @returns {Promise}
+ */
+export async function rollBonusDie(formula, actor) {
+ const roll = new Roll(formula)
+ await roll.evaluate()
+ if (game?.dice3d) {
+ await game.dice3d.showForRoll(roll, game.user, true)
+ }
+ return roll.total
+}
+
+export async function rerollConfiguredRoll(rerollContext = {}) {
+ const RollClass = CONFIG.Dice.rolls.find(r => r.name === "LethalFantasyRoll")
+ if (typeof RollClass?.prompt !== "function") {
+ ui.notifications.error("Lethal Fantasy roll class not available for reroll")
+ return null
+ }
+
+ return await RollClass.prompt({
+ ...foundry.utils.duplicate(rerollContext),
+ rollContext: foundry.utils.duplicate(rerollContext.rollContext || {}),
+ hasTarget: false,
+ target: false
+ })
+}
+
+export async function offerAttackerGritBonus(attacker, currentAttackRoll, defenseRoll, attackerName, defenderName) {
+ let totalBonus = 0
+ let keepOffering = true
+
+ while (keepOffering && currentAttackRoll + totalBonus <= defenseRoll) {
+ const currentGrit = attacker.system.grit.current
+
+ if (currentGrit <= 0) {
+ break
+ }
+
+ const buttons = [
+ {
+ action: "grit",
+ type: "button",
+ label: `Spend 1 Grit (+1D6) [${currentGrit} left]`,
+ icon: "fa-solid fa-fist-raised",
+ callback: () => "grit"
+ },
+ {
+ action: "continue",
+ type: "button",
+ label: "Continue (no bonus)",
+ icon: "fa-solid fa-forward",
+ callback: () => "continue"
+ }
+ ]
+
+ const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/attack-grit.hbs", {
+ attackerName,
+ currentAttackRollWithBonus: currentAttackRoll + totalBonus,
+ defenderName,
+ defenseRoll,
+ totalBonus
+ })
+
+ const choice = await foundry.applications.api.DialogV2.wait({
+ window: { title: "Attack with Grit" },
+ classes: ["lethalfantasy"],
+ content,
+ buttons,
+ rejectClose: false
+ })
+
+ if (!choice || choice === "continue") {
+ keepOffering = false
+ break
+ }
+
+ const bonusRoll = new Roll("1d6")
+ await bonusRoll.evaluate()
+
+ if (game?.dice3d) {
+ await game.dice3d.showForRoll(bonusRoll, game.user, true)
+ }
+
+ totalBonus += bonusRoll.total
+
+ await attacker.update({ "system.grit.current": currentGrit - 1 })
+ const gritRm = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"grit", actorName:attackerName, resource:"Grit", value:bonusRoll.total, side:"attack"})
+ await ChatMessage.create({content: gritRm, speaker: ChatMessage.getSpeaker({actor: attacker})})
+ }
+
+ return totalBonus
+}
+
+export async function compareAttackDefense(data) {
+ log("compareAttackDefense called with:", data)
+
+ // Compute D30 effects from the attack D30 message directly.
+ // This is more reliable than depending on the caller-provided values, which are
+ // computed per-client and may differ between clients due to cross-client processing order.
+ const d30DamageMultiplier = data.attackD30message?.type === "damage_multiplier"
+ ? data.attackD30message.multiplier
+ : (data.d30DamageMultiplier || 1)
+ const d30Bleed = data.attackD30message?.type === "combo"
+ ? (data.attackD30message.effects?.some(e => e.type === "bleed" || e.type === "internal_injury") ? "true" : "")
+ : data.attackD30message?.type === "bleed" ? "true" : (data.d30Bleed || "")
+ const d30DrMultiplier = data.d30DrMultiplier || 1
+
+ const shieldDamageReduction = data.shieldDamageReduction || 0
+ const outcome = data.outcome || (data.attackRoll > data.defenseRoll ? "hit" : "miss")
+ const isAttackWin = outcome !== "miss"
+ log("isAttackWin:", isAttackWin, "attackRoll:", data.attackRoll, "defenseRoll:", data.defenseRoll)
+
+ let damageButton = ""
+ if (isAttackWin && (data.attackWeaponId || data.attackRollKey)) {
+ log("Creating damage button. defenderId:", data.defenderId)
+ // Déterminer le type de dégâts à lancer
+ if (data.attackRollType === "weapon-attack") {
+ damageButton = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/damage-button.hbs", {
+ type: "weapon",
+ attackerId: data.attackerId,
+ defenderId: data.defenderId,
+ defenderTokenId: data.defenderTokenId || "",
+ shieldDamageReduction: shieldDamageReduction,
+ attackWeaponId: data.attackWeaponId,
+ d30Bleed,
+ d30DamageMultiplier,
+ d30DrMultiplier
+ })
+ } else if (data.attackRollType === "monster-attack") {
+ damageButton = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/damage-button.hbs", {
+ type: "monster",
+ attackerId: data.attackerId,
+ defenderId: data.defenderId,
+ defenderTokenId: data.defenderTokenId || "",
+ shieldDamageReduction: shieldDamageReduction,
+ attackRollKey: data.attackRollKey,
+ d30Bleed,
+ d30DamageMultiplier,
+ d30DrMultiplier
+ })
+ } else if (data.attackRollType === "spell-attack" || data.attackRollType === "miracle-attack") {
+ const attacker = game.actors.get(data.attackerId)
+ const spell = attacker?.items.get(data.attackWeaponId || data.attackRollKey)
+ const chosenTier = data.damageTier || "standard"
+ const allTiers = [
+ { id: "standard", formula: spell?.system?.damageDice, label: "Standard" },
+ { id: "overpowered", formula: spell?.system?.damageDiceOverpowered, label: "Overpowered" },
+ { id: "overpowered2", formula: spell?.system?.damageDiceOverpowered2, label: "Overpowered 2" },
+ ]
+ const tierData = allTiers.filter(t => t.id === chosenTier && t.formula).map(t => ({
+ formula: Handlebars.escapeExpression(t.formula),
+ label: t.label
+ }))
+ if (tierData.length) {
+ damageButton = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/damage-button.hbs", {
+ type: "spell",
+ attackerId: data.attackerId,
+ defenderId: data.defenderId,
+ defenderTokenId: data.defenderTokenId || "",
+ tiers: tierData,
+ d30Bleed,
+ d30DamageMultiplier,
+ d30DrMultiplier
+ })
+ }
+ }
+ }
+
+ const resultMessage = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/combat-result.hbs", {
+ isAttackWin,
+ outcome,
+ attackerName: data.attackerName,
+ defenderName: data.defenderName,
+ attackRoll: data.attackRoll,
+ defenseRoll: data.defenseRoll,
+ shieldDamageReduction: shieldDamageReduction,
+ damageButton
+ })
+
+ log("Creating combat result message...")
+ await ChatMessage.create({
+ content: resultMessage,
+ speaker: { alias: "Combat System" }
+ })
+ log("Combat result message created!")
+}
+
+export async function applyDamage(message, event) {
+ // Récupérer les données du message
+ let combatantId = event.currentTarget.dataset.combatantId
+ if (!combatantId) {
+ ui.notifications.error("No combatant selected")
+ return
+ }
+
+ // Try to find the target: first as a combat combatant, then as a scene token
+ let targetActor = null
+ if (game.combat) {
+ const combatant = game.combat.combatants.get(combatantId)
+ if (combatant) {
+ targetActor = combatant.token?.actor || game.actors.get(combatant.actorId)
+ }
+ }
+ if (!targetActor) {
+ // Fall back to scene token lookup (non-combat tokens use tokenId as their combatantId)
+ const token = canvas.tokens?.placeables?.find(t => t.id === combatantId)
+ targetActor = token?.actor
+ }
+ if (!targetActor) {
+ ui.notifications.error("Target actor not found")
+ return
+ }
+
+ // Récupérer les données de dégâts du message
+ // Use options.rollTotal (includes weapon modifier bonus) rather than roll.total (dice formula only)
+ let damageTotal = message.rolls[0]?.options?.rollTotal ?? message.rolls[0]?.total ?? 0
+ let weaponName = message.rolls[0]?.options?.rollName || "Unknown Weapon"
+
+ // Calculer les DR
+ let armorDR = targetActor.computeDamageReduction() || 0
+ let shieldDR = targetActor.getShieldDR() || 0
+ let totalDR = armorDR + shieldDR
+
+ // Créer le dialogue
+ const content = await foundry.applications.handlebars.renderTemplate(
+ "systems/fvtt-lethal-fantasy/templates/apply-damage-dialog.hbs",
+ {
+ targetName: targetActor.name,
+ weaponName: weaponName,
+ damageTotal: damageTotal,
+ armorDR: armorDR,
+ shieldDR: shieldDR,
+ totalDR: totalDR,
+ damageNoDR: damageTotal,
+ damageWithArmor: Math.max(0, damageTotal - armorDR),
+ damageWithAll: Math.max(0, damageTotal - totalDR)
+ }
+ )
+
+ const result = await foundry.applications.api.DialogV2.wait({
+ window: { title: "Apply Damage" },
+ classes: ["lethalfantasy"],
+ position: { width: 280 },
+ content,
+ buttons: [
+ {
+ action: "noDR",
+ type: "button",
+ label: "No DR",
+ callback: () => ({ drType: "none", damage: damageTotal })
+ },
+ {
+ action: "armorDR",
+ type: "button",
+ label: "With Armor DR",
+ callback: () => ({ drType: "armor", damage: Math.max(0, damageTotal - armorDR) })
+ },
+ {
+ action: "allDR",
+ type: "button",
+ label: "With Armor + Shield DR",
+ callback: () => ({ drType: "all", damage: Math.max(0, damageTotal - totalDR) })
+ },
+ {
+ action: "cancel",
+ type: "button",
+ label: "Cancel",
+ callback: () => null
+ }
+ ],
+ rejectClose: false
+ })
+
+ if (result && result.damage !== undefined) {
+ await targetActor.applyDamage(-result.damage)
+
+ // Message de confirmation
+ let drText = ""
+ if (result.drType === "armor") {
+ drText = `Armor DR: ${armorDR}`
+ } else if (result.drType === "all") {
+ drText = `Total DR: ${totalDR}`
+ }
+
+ const messageContent = await foundry.applications.handlebars.renderTemplate(
+ "systems/fvtt-lethal-fantasy/templates/damage-applied-message.hbs",
+ {
+ targetName: targetActor.name,
+ damage: result.damage,
+ drText: drText,
+ weaponName: weaponName
+ }
+ )
+
+ await ChatMessage.create({
+ user: game.user.id,
+ speaker: { alias: targetActor.name },
+ mode: "gm",
+ content: messageContent
+ })
+ }
+}
diff --git a/module/utils/d30.mjs b/module/utils/d30.mjs
new file mode 100644
index 0000000..0d747d8
--- /dev/null
+++ b/module/utils/d30.mjs
@@ -0,0 +1,183 @@
+import { SYSTEM } from "../config/system.mjs"
+export { log } from "./helpers.mjs"
+
+export function hasD30Reroll(d30Message) {
+ return d30Message?.type === "mulligan"
+}
+
+/**
+ * Process D30 bonus dice for attack or defense.
+ * Rolls and applies bonus dice BEFORE grit/luck/shield decisions.
+ * For `choice` type results (D30=20, 30), shows dialog to choose between bonus dice and special effect.
+ * For `bonus_dice` type results (D30=27, 2, 3), auto-rolls the dice.
+ * @param {Object|null} d30Message The D30 result object
+ * @param {"attack"|"defense"} side Whether processing the attack or defense side
+ * @param {number|null} naturalRoll The natural D20 roll (for special strike type detection)
+ * @param {Object} actor The actor (for dice3d display)
+ * @returns {Promise<{modifier: number, specialEffect: string|null, specialName: string|null}>}
+ */
+export async function processD30BonusDice(d30Message, side, naturalRoll = null, actor = null, canDialog = true) {
+ if (!d30Message) return { modifier: 0, specialEffect: null, specialName: null }
+
+ const validTargets = side === "attack" ? ["attack", "spell_attack"] : ["defense", "spell_defense"]
+
+ // ── Simple bonus_dice type ── auto-roll if target matches
+ if (d30Message.type === "bonus_dice") {
+ if (!validTargets.includes(d30Message.target)) return { modifier: 0, specialEffect: null, specialName: null }
+ const modifier = await _rollD30BonusDie(d30Message.dice, actor, !canDialog)
+ return { modifier, specialEffect: null, specialName: null }
+ }
+
+ // ── Choice type ── present all options to the player
+ if (d30Message.type === "choice") {
+ // If we can't show dialogs (wrong client), skip — the primary client
+ // will communicate its choice result via socket. Auto-rolling here
+ // would give a different modifier on each client, causing divergence.
+ if (!canDialog) {
+ return { modifier: 0, specialEffect: null, specialName: null }
+ }
+
+ const buttons = d30Message.choices.map(c => {
+ let label
+ let icon
+ if (c.type === "bonus_dice") {
+ label = `Roll ${c.dice.toUpperCase()} and add to ${side}`
+ icon = "fa-solid fa-dice"
+ } else if (c.type === "special_strike") {
+ label = _buildSpecialLabel(c, naturalRoll)
+ icon = "fa-solid fa-star"
+ } else if (c.type === "special_defense") {
+ label = _buildSpecialLabel(c, naturalRoll)
+ icon = "fa-solid fa-shield-halved"
+ } else {
+ label = c.type.replace(/_/g, " ").replace(/\b\w/g, l => l.toUpperCase())
+ icon = "fa-solid fa-question"
+ }
+ return {
+ action: c.type,
+ type: "button",
+ label,
+ icon,
+ callback: () => c
+ }
+ })
+
+ const choice = await foundry.applications.api.DialogV2.wait({
+ window: { title: "D30 Special — Choose Effect" },
+ classes: ["lethalfantasy"],
+ content: await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/d30-special-choice.hbs", {
+ description: d30Message.description
+ }),
+ buttons,
+ rejectClose: false
+ })
+
+ if (!choice) return { modifier: 0, specialEffect: null, specialName: null }
+
+ if (choice.type === "bonus_dice") {
+ const modifier = await _rollD30BonusDie(choice.dice, actor)
+ return { modifier, specialEffect: null, specialName: null }
+ }
+
+ if (choice.type === "special_strike" || choice.type === "special_defense") {
+ return { modifier: 0, specialEffect: "auto", specialName: _buildSpecialName(choice, naturalRoll) }
+ }
+
+ // Non-standard choice (spell_calamity, etc.) — report it
+ return { modifier: 0, specialEffect: "flag", specialName: choice.type }
+ }
+
+ // ── Combo type (bleed / internal injury) — flag for wound creation
+ if (d30Message.type === "combo") {
+ const hasBleed = d30Message.effects?.some(e => e.type === "bleed" || e.type === "internal_injury")
+ if (hasBleed) {
+ return { modifier: 0, specialEffect: "bleed", specialName: "Bleeding/Internal Injury" }
+ }
+ }
+
+ // ── Damage multiplier type (2x/3x damage before DR)
+ if (d30Message.type === "damage_multiplier") {
+ return { modifier: 0, specialEffect: "damageMultiplier", specialName: `x${d30Message.multiplier} Damage`, multiplier: d30Message.multiplier }
+ }
+
+ // ── DR multiplier type (2x/3x DR including shield)
+ if (d30Message.type === "dr_multiplier") {
+ return { modifier: 0, specialEffect: "drMultiplier", specialName: `x${d30Message.multiplier} DR`, multiplier: d30Message.multiplier }
+ }
+
+ return { modifier: 0, specialEffect: null, specialName: null }
+}
+
+/**
+ * Roll a D30 bonus die and show with 3D dice if available.
+ * @param {string} formula Dice formula (e.g. "D6", "D12", "D20E")
+ * @param {Object} actor Actor for chat message speaker
+ * @returns {Promise} The roll total
+ */
+export async function _rollD30BonusDie(formula, actor, silent = false) {
+ const cleaned = formula.replace(/NE$/i, "").replace("E", "")
+ const roll = new Roll(cleaned)
+ await roll.evaluate()
+ if (game?.dice3d) {
+ await game.dice3d.showForRoll(roll, game.user, true)
+ }
+ if (!silent) {
+ await ChatMessage.create({
+ content: await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"d30BonusRoll", formula: cleaned.toUpperCase(), value: roll.total}),
+ speaker: ChatMessage.getSpeaker({ actor })
+ })
+ }
+ return roll.total
+}
+
+/**
+ * Build a human-readable label for a special strike/defense choice in the D30 prompt.
+ * @param {Object} specialChoice The choice object with type and options
+ * @param {number|null} naturalRoll The natural D20 roll
+ * @returns {string} Display label
+ */
+export function _buildSpecialLabel(specialChoice, naturalRoll) {
+ if (specialChoice.type === "special_strike") {
+ if (specialChoice.options.includes("lethal")) {
+ if (naturalRoll === 20) return "Lethal Strike (auto-hit)"
+ if (naturalRoll >= 16 && naturalRoll <= 19) return "Vital Strike (auto-hit)"
+ return "Lethal/Vital Strike (auto-hit)"
+ }
+ if (specialChoice.options.includes("vicious")) return "Vicious Strike (auto-hit)"
+ return "Special Strike (auto-hit)"
+ }
+ if (specialChoice.type === "special_defense") {
+ if (specialChoice.options.includes("perfect_spell")) return "Perfect Spell Defense (auto-block)"
+ if (specialChoice.options.includes("flawless")) return "Flawless Defense (auto-block)"
+ if (specialChoice.options.includes("legendary")) return "Legendary Defense (auto-block)"
+ if (specialChoice.options.includes("perfect")) return "Perfect Defense (auto-block)"
+ return "Special Defense (auto-block)"
+ }
+ return "Special Effect"
+}
+
+/**
+ * Build the special effect name based on the D30 result and natural roll.
+ * @param {Object} specialChoice The choice object with type and options
+ * @param {number|null} naturalRoll The natural D20 roll
+ * @returns {string} The special effect name
+ */
+export function _buildSpecialName(specialChoice, naturalRoll) {
+ if (specialChoice.type === "special_strike") {
+ if (specialChoice.options.includes("lethal")) {
+ if (naturalRoll === 20) return "Lethal Strike"
+ if (naturalRoll >= 16 && naturalRoll <= 19) return "Vital Strike"
+ return "Lethal/Vital Strike"
+ }
+ if (specialChoice.options.includes("vicious")) return "Vicious Strike"
+ return "Special Strike"
+ }
+ if (specialChoice.type === "special_defense") {
+ if (specialChoice.options.includes("perfect_spell")) return "Perfect Spell Defense"
+ if (specialChoice.options.includes("flawless")) return "Flawless Defense"
+ if (specialChoice.options.includes("legendary")) return "Legendary Defense"
+ if (specialChoice.options.includes("perfect")) return "Perfect Defense"
+ return "Special Defense"
+ }
+ return "Special Effect"
+}
diff --git a/module/utils/helpers.mjs b/module/utils/helpers.mjs
new file mode 100644
index 0000000..6bab04c
--- /dev/null
+++ b/module/utils/helpers.mjs
@@ -0,0 +1,303 @@
+import { SYSTEM } from "../config/system.mjs"
+
+export function log(...args) {
+ if (game?.settings?.get(game.system.id, "debug")) {
+ console.log(...args)
+ }
+}
+
+export async function loadCompendiumData(compendium) {
+ const pack = game.packs.get(compendium)
+ return await pack?.getDocuments() ?? []
+}
+
+export async function loadCompendium(compendium, filter = item => true) {
+ let compendiumData = await loadCompendiumData(compendium)
+ return compendiumData.filter(filter)
+}
+
+export function pushCombatOptions(html, options) {
+ options.push({ name: "Reset Progression", condition: true, icon: ' ', callback: target => { game.combat.resetProgression(target.data('combatant-id')); } })
+}
+
+export function setHookListeners() {
+
+ Hooks.on('renderTokenHUD', async (hud, html, data) => {
+ if (html.querySelector(".lethal-hp-loss-hud")) return
+ // The token/actor is on the HUD application instance, not the third param.
+ // hud.token / hud.object gives the Token (PlaceableObject), which has .actor.
+ const hudActor = hud.token?.actor ?? hud.object?.actor
+ if (!hudActor) return
+ // HP Loss Button (existing)
+ const lossHPButton = await foundry.applications.handlebars.renderTemplate('systems/fvtt-lethal-fantasy/templates/loss-hp-hud.hbs', {})
+ $(html).find('div.left').append(lossHPButton);
+ $(html).find('img.lethal-hp-loss-hud').click((event) => {
+ event.preventDefault();
+ let hpMenu = $(html).find('.hp-loss-wrap')[0]
+ if (hpMenu.classList.contains("hp-loss-hud-disabled")) {
+ $(html).find('.hp-loss-wrap')[0].classList.add('hp-loss-hud-active');
+ $(html).find('.hp-loss-wrap')[0].classList.remove('hp-loss-hud-disabled');
+ $(html).find('.hp-loss-wrap')[1].classList.add('hp-loss-hud-active');
+ $(html).find('.hp-loss-wrap')[1].classList.remove('hp-loss-hud-disabled');
+ $(html).find('.hp-loss-wrap')[2].classList.add('hp-loss-hud-active');
+ $(html).find('.hp-loss-wrap')[2].classList.remove('hp-loss-hud-disabled');
+ } else {
+ $(html).find('.hp-loss-wrap')[0].classList.remove('hp-loss-hud-active');
+ $(html).find('.hp-loss-wrap')[0].classList.add('hp-loss-hud-disabled');
+ $(html).find('.hp-loss-wrap')[1].classList.remove('hp-loss-hud-active');
+ $(html).find('.hp-loss-wrap')[1].classList.add('hp-loss-hud-disabled');
+ $(html).find('.hp-loss-wrap')[2].classList.remove('hp-loss-hud-active');
+ $(html).find('.hp-loss-wrap')[2].classList.add('hp-loss-hud-disabled');
+ }
+ })
+ $(html).find('.loss-hp-hud-click').click(async (event) => {
+ event.preventDefault();
+ let hpLoss = event.currentTarget.dataset.hpValue;
+ await hudActor.applyDamage(Number(hpLoss));
+ $(html).find('.hp-loss-wrap')[0].classList.remove('hp-loss-hud-active');
+ $(html).find('.hp-loss-wrap')[0].classList.add('hp-loss-hud-disabled');
+ $(html).find('.hp-loss-wrap')[1].classList.remove('hp-loss-hud-active');
+ $(html).find('.hp-loss-wrap')[1].classList.add('hp-loss-hud-disabled');
+ $(html).find('.hp-loss-wrap')[2].classList.remove('hp-loss-hud-active');
+ $(html).find('.hp-loss-wrap')[2].classList.add('hp-loss-hud-disabled');
+ })
+
+ // HP Gain Button (new)
+ const gainHPButton = await foundry.applications.handlebars.renderTemplate('systems/fvtt-lethal-fantasy/templates/gain-hp-hud.hbs', {})
+ $(html).find('div.left').append(gainHPButton);
+ $(html).find('img.lethal-hp-gain-hud').click((event) => {
+ event.preventDefault();
+ let hpMenu = $(html).find('.hp-gain-wrap')[0]
+ if (hpMenu.classList.contains("hp-gain-hud-disabled")) {
+ $(html).find('.hp-gain-wrap')[0].classList.add('hp-gain-hud-active');
+ $(html).find('.hp-gain-wrap')[0].classList.remove('hp-gain-hud-disabled');
+ $(html).find('.hp-gain-wrap')[1].classList.add('hp-gain-hud-active');
+ $(html).find('.hp-gain-wrap')[1].classList.remove('hp-gain-hud-disabled');
+ $(html).find('.hp-gain-wrap')[2].classList.add('hp-gain-hud-active');
+ $(html).find('.hp-gain-wrap')[2].classList.remove('hp-gain-hud-disabled');
+ } else {
+ $(html).find('.hp-gain-wrap')[0].classList.remove('hp-gain-hud-active');
+ $(html).find('.hp-gain-wrap')[0].classList.add('hp-gain-hud-disabled');
+ $(html).find('.hp-gain-wrap')[1].classList.remove('hp-gain-hud-active');
+ $(html).find('.hp-gain-wrap')[1].classList.add('hp-gain-hud-disabled');
+ $(html).find('.hp-gain-wrap')[2].classList.remove('hp-gain-hud-active');
+ $(html).find('.hp-gain-wrap')[2].classList.add('hp-gain-hud-disabled');
+ }
+ })
+ $(html).find('.gain-hp-hud-click').click(async (event) => {
+ event.preventDefault();
+ let hpGain = event.currentTarget.dataset.hpValue;
+ await hudActor.applyDamage(Number(hpGain)); // Positive value to add HP
+ // Clear bleeding wounds on heal — regardless of heal amount, any
+ // healing is enough to stop bleeding (field dressing / magic / rest).
+ const wounds = foundry.utils.duplicate(hudActor.system.hp.wounds || [])
+ const hadBleeding = wounds.some(w => w.description === "Bleeding")
+ if (hadBleeding) {
+ await hudActor.update({
+ "system.hp.wounds": wounds.map(w =>
+ w.description === "Bleeding" ? { value: 0, duration: 0 } : w
+ )
+ })
+ }
+ $(html).find('.hp-gain-wrap')[0].classList.remove('hp-gain-hud-active');
+ $(html).find('.hp-gain-wrap')[0].classList.add('hp-gain-hud-disabled');
+ $(html).find('.hp-gain-wrap')[1].classList.remove('hp-gain-hud-active');
+ $(html).find('.hp-gain-wrap')[1].classList.add('hp-gain-hud-disabled');
+ $(html).find('.hp-gain-wrap')[2].classList.remove('hp-gain-hud-active');
+ $(html).find('.hp-gain-wrap')[2].classList.add('hp-gain-hud-disabled');
+ })
+
+ // Luck/Grit Buttons
+ const luckGritButton = await foundry.applications.handlebars.renderTemplate('systems/fvtt-lethal-fantasy/templates/luck-grit-hud.hbs', {})
+ $(html).find('div.left').append(luckGritButton);
+ $(html).find('.lethal-luck-grit-hud').click((event) => {
+ event.preventDefault();
+ let wrap = $(html).find('.luck-grit-wrap')[0]
+ if (wrap.classList.contains("luck-grit-hud-disabled")) {
+ wrap.classList.add('luck-grit-hud-active');
+ wrap.classList.remove('luck-grit-hud-disabled');
+ } else {
+ wrap.classList.remove('luck-grit-hud-active');
+ wrap.classList.add('luck-grit-hud-disabled');
+ }
+ })
+ $(html).find('.luck-grit-btn').click(async (event) => {
+ event.preventDefault();
+ const resource = event.currentTarget.dataset.resource;
+ const amount = Number(event.currentTarget.dataset.amount);
+ const current = Number(foundry.utils.getProperty(hudActor.system, `${resource}.current`)) || 0;
+ const newValue = Math.max(0, current + amount);
+ await hudActor.update({ [`system.${resource}.current`]: newValue });
+ $(html).find('.luck-grit-wrap')[0].classList.remove('luck-grit-hud-active');
+ $(html).find('.luck-grit-wrap')[0].classList.add('luck-grit-hud-disabled');
+ })
+ })
+}
+
+export function registerHandlebarsHelpers() {
+
+ Handlebars.registerHelper('isNull', function (val) {
+ return val == null;
+ });
+ Handlebars.registerHelper('match', function (val, search) {
+ if (val && search) {
+ return val?.match(search);
+ }
+ return false
+ });
+
+ Handlebars.registerHelper('exists', function (val) {
+ return val != null && val !== undefined;
+ });
+
+ Handlebars.registerHelper('isEmpty', function (list) {
+ if (list) return list.length === 0;
+ else return false;
+ });
+
+ Handlebars.registerHelper('notEmpty', function (list) {
+ return list.length > 0;
+ });
+
+ Handlebars.registerHelper('isNegativeOrNull', function (val) {
+ return val <= 0;
+ });
+
+ Handlebars.registerHelper('isNegative', function (val) {
+ return val < 0;
+ });
+
+ Handlebars.registerHelper('isPositive', function (val) {
+ return val > 0;
+ });
+
+ Handlebars.registerHelper('equals', function (val1, val2) {
+ return val1 === val2;
+ });
+
+ Handlebars.registerHelper('neq', function (val1, val2) {
+ return val1 !== val2;
+ });
+
+ Handlebars.registerHelper('gt', function (val1, val2) {
+ return val1 > val2;
+ })
+
+ Handlebars.registerHelper('lt', function (val1, val2) {
+ return val1 < val2;
+ })
+
+ Handlebars.registerHelper('gte', function (val1, val2) {
+ return val1 >= val2;
+ })
+
+ Handlebars.registerHelper('lte', function (val1, val2) {
+ return val1 <= val2;
+ })
+ Handlebars.registerHelper('and', function (val1, val2) {
+ return val1 && val2;
+ })
+ Handlebars.registerHelper('or', function (val1, val2) {
+ return val1 || val2;
+ })
+
+ Handlebars.registerHelper('or3', function (val1, val2, val3) {
+ return val1 || val2 || val3;
+ })
+
+ Handlebars.registerHelper('for', function (from, to, incr, block) {
+ let accum = '';
+ for (let i = from; i < to; i += incr)
+ accum += block.fn(i);
+ return accum;
+ })
+
+ Handlebars.registerHelper('not', function (cond) {
+ return !cond;
+ })
+ Handlebars.registerHelper('count', function (list) {
+ return list.length;
+ })
+ Handlebars.registerHelper('countKeys', function (obj) {
+ return Object.keys(obj).length;
+ })
+
+ Handlebars.registerHelper('isEnabled', function (configKey) {
+ return game.settings.get("bol", configKey);
+ })
+ Handlebars.registerHelper('split', function (str, separator, keep) {
+ return str.split(separator)[keep];
+ })
+
+ // If you need to add Handlebars helpers, here are a few useful examples:
+ Handlebars.registerHelper('concat', function () {
+ let outStr = '';
+ for (let arg in arguments) {
+ if (typeof arguments[arg] != 'object') {
+ outStr += arguments[arg];
+ }
+ }
+ return outStr;
+ })
+
+ Handlebars.registerHelper('add', function (a, b) {
+ return parseInt(a) + parseInt(b);
+ });
+ Handlebars.registerHelper('mul', function (a, b) {
+ return parseInt(a) * parseInt(b);
+ })
+ Handlebars.registerHelper('sub', function (a, b) {
+ return parseInt(a) - parseInt(b);
+ })
+ Handlebars.registerHelper('abbrev2', function (a) {
+ return a.substring(0, 2);
+ })
+ Handlebars.registerHelper('abbrev3', function (a) {
+ return a.substring(0, 3);
+ })
+ Handlebars.registerHelper('valueAtIndex', function (arr, idx) {
+ return arr[idx];
+ })
+ Handlebars.registerHelper('includesKey', function (items, type, key) {
+ return items.filter(i => i.type === type).map(i => i.system.key).includes(key);
+ })
+ Handlebars.registerHelper('includes', function (array, val) {
+ return array.includes(val);
+ })
+ Handlebars.registerHelper('eval', function (expr) {
+ return eval(expr);
+ })
+ Handlebars.registerHelper('isOwnerOrGM', function (actor) {
+ log("Testing actor", actor.isOwner, game.userId)
+ return actor.isOwner || game.isGM;
+ })
+ Handlebars.registerHelper('upperCase', function (text) {
+ if (typeof text !== 'string') return text
+ return text.toUpperCase()
+ })
+ Handlebars.registerHelper('upperFirst', function (text) {
+ if (typeof text !== 'string') return text
+ return text.charAt(0).toUpperCase() + text.slice(1)
+ })
+ Handlebars.registerHelper('upperFirstOnly', function (text) {
+ if (typeof text !== 'string') return text
+ return text.charAt(0).toUpperCase()
+ })
+
+ // Handle v12 removal of this helper
+ Handlebars.registerHelper('select', function (selected, options) {
+ const escapedValue = RegExp.escape(Handlebars.escapeExpression(selected));
+ const rgx = new RegExp(' value=[\"\']' + escapedValue + '[\"\']');
+ const html = options.fn(this);
+ return html.replace(rgx, "$& selected");
+ });
+
+}
+
+export function getLethargyDice(level) {
+ for (let s of SYSTEM.SPELL_LETHARGY_DICE) {
+ if (Number(level) <= s.maxLevel) {
+ return s.dice
+ }
+ }
+}
diff --git a/templates/chat/_dice-breakdown.hbs b/templates/chat/_dice-breakdown.hbs
new file mode 100644
index 0000000..c01ff31
--- /dev/null
+++ b/templates/chat/_dice-breakdown.hbs
@@ -0,0 +1,14 @@
+
+ {{#each diceResults}}
+
+ {{this.dice}}
+ →
+ {{this.value}}
+
+ {{/each}}
+
+{{#if D30message}}
+
+ D30 → {{D30result}} — {{D30message.description}}
+
+{{/if}}
diff --git a/templates/chat/combat-result.hbs b/templates/chat/combat-result.hbs
new file mode 100644
index 0000000..688cb8a
--- /dev/null
+++ b/templates/chat/combat-result.hbs
@@ -0,0 +1,30 @@
+
+
Combat Result
+
+
+
{{localize "LETHALFANTASY.Combat.attacker"}}
+
+
{{attackerName}}
+
{{attackRoll}}
+
+
+
VS
+
+
{{localize "LETHALFANTASY.Combat.defender"}}
+
+
{{defenderName}}
+
{{defenseRoll}}
+
+
+
+
+ {{#if (equals outcome "shielded-hit")}}
+ {{defenderName}} has blocked with shield — apply armor DR + shield DR {{shieldDamageReduction}} .
+ {{else if isAttackWin}}
+ {{attackerName}} hits {{defenderName}} !
+ {{else}}
+ {{defenderName}} avoided the attack!
+ {{/if}}
+
+ {{{damageButton}}}
+
diff --git a/templates/chat/damage-button.hbs b/templates/chat/damage-button.hbs
new file mode 100644
index 0000000..457d94b
--- /dev/null
+++ b/templates/chat/damage-button.hbs
@@ -0,0 +1,47 @@
+{{#if (equals type "weapon")}}
+
+
+ Damage
+
+
+{{else if (equals type "monster")}}
+
+
+ Damage
+
+
+{{else if (equals type "spell")}}
+
+ {{#each tiers}}
+
+ {{this.label}} ({{this.formula}})
+
+ {{/each}}
+
+{{/if}}
diff --git a/templates/chat/dice-rolls.hbs b/templates/chat/dice-rolls.hbs
new file mode 100644
index 0000000..85d68ca
--- /dev/null
+++ b/templates/chat/dice-rolls.hbs
@@ -0,0 +1 @@
+
diff --git a/templates/chat/free-roll-card.hbs b/templates/chat/free-roll-card.hbs
new file mode 100644
index 0000000..f0ba97c
--- /dev/null
+++ b/templates/chat/free-roll-card.hbs
@@ -0,0 +1,20 @@
+
+
+
+ {{#each dieChips}}
+
+ {{this.label}}
+ →
+ {{this.value}}{{#if this.exploded}} {{/if}}
+
+ {{/each}}
+
+
+ {{totalLabel}}
+ {{total}}
+
+
diff --git a/templates/chat/reaction-message.hbs b/templates/chat/reaction-message.hbs
new file mode 100644
index 0000000..c4f8d48
--- /dev/null
+++ b/templates/chat/reaction-message.hbs
@@ -0,0 +1,42 @@
+{{#if (equals type "aetherSpend")}}
+🔮 {{actorName}} casts {{spellName}}{{#if tierLabel}}{{tierLabel}}{{/if}} — spends {{value}} Aether ({{oldValue}} → {{newValue}}) .
+{{else if (equals type "graceSpend")}}
+✨ {{actorName}} invokes {{spellName}} — spends {{value}} Grace ({{oldValue}} → {{newValue}}) .
+{{else if (equals type "bleedingNotice")}}
+Bleeding: Wound of {{value}} HP for {{value}} seconds.
+{{else if (equals type "d30BonusRoll")}}
+D30 bonus: rolled {{formula}} = {{value}}
+{{else if (equals type "mulligan")}}
+{{actorName}} uses Mulligan and re-rolls {{side}}: {{oldRoll}} → {{newRoll}} .
+{{> chat/dice-breakdown diceResults=diceResults D30result=D30result D30message=D30message}}
+Both sides may now react to the new numbers.
+{{else}}
+
+ {{actorName}}
+ {{#if (equals type "grit")}}
+ spends 1 {{resource}} and rolls {{value}} for {{side}}.
+ {{else if (equals type "luck")}}
+ spends 1 {{resource}} and rolls {{value}} for {{side}}.
+ {{else if (equals type "bonusDie")}}
+ adds {{formula}} and rolls {{value}} for {{side}}.
+ {{else if (equals type "d30Bonus")}}
+ gains +{{value}} from D30 bonus die for {{side}}.
+ {{else if (equals type "d30Auto")}}
+ uses {{specialName}} from D30 — {{#if (equals side "defense")}}defense automatically succeeds!{{else}}attack automatically hits!{{/if}}
+ {{else if (equals type "d30Flag")}}
+ D30 — {{specialName}} triggered for {{actorName}}!
+ {{else if (equals type "d30Bleed")}}
+ D30 — Bleeding/Internal Injury on hit! Damage past DR will cause a bleeding wound.
+ {{else if (equals type "d30DamageMultiplier")}}
+ D30 — x{{value}} damage before damage reduction!
+ {{else if (equals type "d30DRMultiplier")}}
+ D30 — Defense grants x{{value}} DR (choose which DR types to multiply when damage is applied).
+ {{else if (equals type "shieldBlock")}}
+ rolls {{shieldLabel}} and adds {{shieldBonus}} to defense ({{newTotal}} ≥ {{opposingRoll}}). Shield blocked the attack! Both armor DR and shield DR {{shieldDR}} will apply to damage.
+ {{else if (equals type "shieldFail")}}
+ rolls {{shieldLabel}} and adds {{shieldBonus}} to defense ({{newTotal}} < {{opposingRoll}}). Shield did not block — normal hit, armor DR only.
+ {{else if (equals type "generic")}}
+ {{{body}}}
+ {{/if}}
+
+{{/if}}
diff --git a/templates/dialogs/ad-hoc-shield.hbs b/templates/dialogs/ad-hoc-shield.hbs
new file mode 100644
index 0000000..eb56d6b
--- /dev/null
+++ b/templates/dialogs/ad-hoc-shield.hbs
@@ -0,0 +1,18 @@
+
+
+
{{defenderName}} uses a shield (not equipped)
+
{{localize "LETHALFANTASY.Combat.attackRoll"}}: {{attackRoll}} — {{localize "LETHALFANTASY.Combat.currentDefense"}}: {{defenseRoll}}
+
+
+ {{localize "LETHALFANTASY.Combat.shieldDice"}}:
+
+ {{#each choices}}
+ {{this}}
+ {{/each}}
+
+
+
+ {{localize "LETHALFANTASY.Combat.shieldDR"}}:
+
+
+
diff --git a/templates/dialogs/attack-grit.hbs b/templates/dialogs/attack-grit.hbs
new file mode 100644
index 0000000..23d3735
--- /dev/null
+++ b/templates/dialogs/attack-grit.hbs
@@ -0,0 +1,8 @@
+
+
+
{{attackerName}} currently has {{currentAttackRollWithBonus}}
+
{{defenderName}} rolled {{defenseRoll}}
+ {{#if totalBonus}}
Bonus already added: +{{totalBonus}}
{{/if}}
+
+
You are losing! Spend Grit to add 1D6 to your attack?
+
diff --git a/templates/dialogs/attack-reaction.hbs b/templates/dialogs/attack-reaction.hbs
new file mode 100644
index 0000000..0ada864
--- /dev/null
+++ b/templates/dialogs/attack-reaction.hbs
@@ -0,0 +1,8 @@
+
+
+
{{attackerName}} currently has {{attackRoll}}
+
{{defenderName}} rolled {{defenseRoll}}
+ {{#if d30message}}
D30 special: {{d30message.description}}
{{/if}}
+
+
{{offerText}}
+
diff --git a/templates/dialogs/bonus-die-select.hbs b/templates/dialogs/bonus-die-select.hbs
new file mode 100644
index 0000000..1ed5c8d
--- /dev/null
+++ b/templates/dialogs/bonus-die-select.hbs
@@ -0,0 +1,14 @@
+
+
+
{{actorName}} currently has {{currentRoll}}
+
{{sideLabel}} opposing roll: {{opposingRoll}}
+
+
+ {{localize "LETHALFANTASY.Combat.chooseBonusDie"}}:
+
+ {{#each choices}}
+ {{this}}
+ {{/each}}
+
+
+
diff --git a/templates/dialogs/combat-monsters-not-rolled.hbs b/templates/dialogs/combat-monsters-not-rolled.hbs
new file mode 100644
index 0000000..dcce067
--- /dev/null
+++ b/templates/dialogs/combat-monsters-not-rolled.hbs
@@ -0,0 +1 @@
+{{message}}
diff --git a/templates/dialogs/d30-dr-multiplier.hbs b/templates/dialogs/d30-dr-multiplier.hbs
new file mode 100644
index 0000000..ba29bed
--- /dev/null
+++ b/templates/dialogs/d30-dr-multiplier.hbs
@@ -0,0 +1,16 @@
+
diff --git a/templates/dialogs/d30-special-choice.hbs b/templates/dialogs/d30-special-choice.hbs
new file mode 100644
index 0000000..43d5e22
--- /dev/null
+++ b/templates/dialogs/d30-special-choice.hbs
@@ -0,0 +1,4 @@
+
+
D30 result: {{description}}
+
{{localize "LETHALFANTASY.D30.chooseEffect"}}
+
diff --git a/templates/dialogs/damage-tier.hbs b/templates/dialogs/damage-tier.hbs
new file mode 100644
index 0000000..573fe9e
--- /dev/null
+++ b/templates/dialogs/damage-tier.hbs
@@ -0,0 +1,4 @@
+
+
{{itemName}} has multiple damage tiers.
+
Choose which damage to use when the attack lands:
+
diff --git a/templates/dialogs/defense-reaction.hbs b/templates/dialogs/defense-reaction.hbs
new file mode 100644
index 0000000..e8155fc
--- /dev/null
+++ b/templates/dialogs/defense-reaction.hbs
@@ -0,0 +1,8 @@
+
+
+
{{attackerName}} {{attackStatus}} {{attackRoll}}
+
{{defenderName}} {{defenseStatus}} {{defenseRoll}}
+ {{#if d30message}}
D30 special: {{d30message.description}}
{{/if}}
+
+
{{offerText}}
+
diff --git a/templates/dialogs/defense-request-character.hbs b/templates/dialogs/defense-request-character.hbs
new file mode 100644
index 0000000..c12d4ce
--- /dev/null
+++ b/templates/dialogs/defense-request-character.hbs
@@ -0,0 +1,14 @@
+
+
+
{{attackerName}} attacks {{defenderName}} with {{weaponName}} !
+
{{localize "LETHALFANTASY.Combat.attackRoll"}}: {{attackRoll}}
+
+
+ {{localize "LETHALFANTASY.Combat.chooseWeapon"}}:
+
+ {{#each weapons}}
+ {{this.name}}
+ {{/each}}
+
+
+
diff --git a/templates/dialogs/defense-request-monster.hbs b/templates/dialogs/defense-request-monster.hbs
new file mode 100644
index 0000000..af4b68f
--- /dev/null
+++ b/templates/dialogs/defense-request-monster.hbs
@@ -0,0 +1,14 @@
+
+
+
{{attackerName}} attacks {{defenderName}} with {{weaponName}} !
+
{{localize "LETHALFANTASY.Combat.attackRoll"}}: {{attackRoll}}
+
+
+ {{localize "LETHALFANTASY.Combat.chooseWeapon"}}:
+
+ {{#each attacks}}
+ {{this.name}}
+ {{/each}}
+
+
+
diff --git a/templates/dialogs/defense-request-save.hbs b/templates/dialogs/defense-request-save.hbs
new file mode 100644
index 0000000..1921ff8
--- /dev/null
+++ b/templates/dialogs/defense-request-save.hbs
@@ -0,0 +1,14 @@
+
+
+
{{attackerName}} targets {{defenderName}} with {{weaponName}} !
+
{{localize "LETHALFANTASY.Combat.attackRoll"}}: {{attackRoll}}
+
+
+ {{localize "LETHALFANTASY.Combat.chooseSave"}}:
+
+ {{#each saves}}
+ {{this.label}}
+ {{/each}}
+
+
+
diff --git a/templates/dialogs/power-level.hbs b/templates/dialogs/power-level.hbs
new file mode 100644
index 0000000..af031ed
--- /dev/null
+++ b/templates/dialogs/power-level.hbs
@@ -0,0 +1 @@
+Select the power level for {{itemName}} :
diff --git a/templates/dialogs/spell-dr.hbs b/templates/dialogs/spell-dr.hbs
new file mode 100644
index 0000000..a7fdb01
--- /dev/null
+++ b/templates/dialogs/spell-dr.hbs
@@ -0,0 +1,7 @@
+
+
{{msg}}
+
+ {{label}}
+
+
+
diff --git a/templates/ui/dice-tray.hbs b/templates/ui/dice-tray.hbs
new file mode 100644
index 0000000..d1c24b8
--- /dev/null
+++ b/templates/ui/dice-tray.hbs
@@ -0,0 +1,17 @@
+
+
+
+ {{#each countOptions}}
+ {{this}}
+ {{/each}}
+
+
+ {{#each diceButtons}}
+ {{this.label}}
+ {{/each}}
+
+
+
+
+
+