From 3df46b58481b67b55ed258972efd7e48df5c871e Mon Sep 17 00:00:00 2001 From: LeRatierBretonnier Date: Sun, 28 Jun 2026 19:13:05 +0200 Subject: [PATCH] refactor: extract inline HTML to templates, split oversized files, fix bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract all inline HTML from JS into 21 Handlebars templates (chat/, dialogs/, ui/) - Split utils.mjs (1507) into barrel + helpers.mjs, combat.mjs, d30.mjs - Split roll.mjs (1632) into barrel + roll-base.mjs, roll-prompt.mjs, roll-combat.mjs, roll-damage.mjs - Split lethal-fantasy.mjs (1426) into bootstrap + chat-reaction.mjs - Fix: missing async on injectDiceTray (free-roll.mjs:29 SyntaxError) - Fix: weapon._id fallback for deserialized chat-message weapon objects - Fix: missing await on rollModifier.evaluate() calls in roll-combat.mjs - Fix: choices→choicesList ReferenceError in utils.mjs - Fix: add 12 missing i18n keys (chooseWeapon, chooseSave, attackRoll, etc.) - Fix: restore sideLabel in bonus-die-select.hbs - Clean: remove dead messageContent param, console.log→log() - Style: barrel files preserve existing import paths --- lang/en.json | 14 +- lethal-fantasy.mjs | 1215 +----------- module/applications/combat.mjs | 3 +- module/applications/free-roll.mjs | 67 +- .../applications/sheets/character-sheet.mjs | 12 +- module/documents/actor.mjs | 2 +- module/documents/chat-message.mjs | 2 +- module/documents/roll-base.mjs | 284 +++ module/documents/roll-combat.mjs | 714 +++++++ module/documents/roll-damage.mjs | 39 + module/documents/roll-prompt.mjs | 598 ++++++ module/documents/roll.mjs | 1633 +--------------- module/hooks/chat-reaction.mjs | 1189 ++++++++++++ module/utils.mjs | 1728 +---------------- module/utils/combat.mjs | 1000 ++++++++++ module/utils/d30.mjs | 183 ++ module/utils/helpers.mjs | 303 +++ templates/chat/_dice-breakdown.hbs | 14 + templates/chat/combat-result.hbs | 30 + templates/chat/damage-button.hbs | 47 + templates/chat/dice-rolls.hbs | 1 + templates/chat/free-roll-card.hbs | 20 + templates/chat/reaction-message.hbs | 42 + templates/dialogs/ad-hoc-shield.hbs | 18 + templates/dialogs/attack-grit.hbs | 8 + templates/dialogs/attack-reaction.hbs | 8 + templates/dialogs/bonus-die-select.hbs | 14 + .../dialogs/combat-monsters-not-rolled.hbs | 1 + templates/dialogs/d30-dr-multiplier.hbs | 16 + templates/dialogs/d30-special-choice.hbs | 4 + templates/dialogs/damage-tier.hbs | 4 + templates/dialogs/defense-reaction.hbs | 8 + .../dialogs/defense-request-character.hbs | 14 + templates/dialogs/defense-request-monster.hbs | 14 + templates/dialogs/defense-request-save.hbs | 14 + templates/dialogs/power-level.hbs | 1 + templates/dialogs/spell-dr.hbs | 7 + templates/ui/dice-tray.hbs | 17 + 38 files changed, 4686 insertions(+), 4602 deletions(-) create mode 100644 module/documents/roll-base.mjs create mode 100644 module/documents/roll-combat.mjs create mode 100644 module/documents/roll-damage.mjs create mode 100644 module/documents/roll-prompt.mjs create mode 100644 module/hooks/chat-reaction.mjs create mode 100644 module/utils/combat.mjs create mode 100644 module/utils/d30.mjs create mode 100644 module/utils/helpers.mjs create mode 100644 templates/chat/_dice-breakdown.hbs create mode 100644 templates/chat/combat-result.hbs create mode 100644 templates/chat/damage-button.hbs create mode 100644 templates/chat/dice-rolls.hbs create mode 100644 templates/chat/free-roll-card.hbs create mode 100644 templates/chat/reaction-message.hbs create mode 100644 templates/dialogs/ad-hoc-shield.hbs create mode 100644 templates/dialogs/attack-grit.hbs create mode 100644 templates/dialogs/attack-reaction.hbs create mode 100644 templates/dialogs/bonus-die-select.hbs create mode 100644 templates/dialogs/combat-monsters-not-rolled.hbs create mode 100644 templates/dialogs/d30-dr-multiplier.hbs create mode 100644 templates/dialogs/d30-special-choice.hbs create mode 100644 templates/dialogs/damage-tier.hbs create mode 100644 templates/dialogs/defense-reaction.hbs create mode 100644 templates/dialogs/defense-request-character.hbs create mode 100644 templates/dialogs/defense-request-monster.hbs create mode 100644 templates/dialogs/defense-request-save.hbs create mode 100644 templates/dialogs/power-level.hbs create mode 100644 templates/dialogs/spell-dr.hbs create mode 100644 templates/ui/dice-tray.hbs 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")}

-
- - -
-
`, - 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 = ` -
-

D30 DR Multiplier ×${d30DrMultiplier}

-

Choose which DR types to multiply:

- - - -
- ` - 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 => - `` - ).join("") - - const countOptions = Array.from({ length: 9 }, (_, i) => - `` - ).join("") - - bar.innerHTML = ` -
- - -
${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 = ` -
-
- - ${game.i18n.localize("LETHALFANTASY.DiceTray.ChatTitle")} - ${label} -
-
${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")}

-
- - -
-
`, + 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 = `
${rollHTML}
` + 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 => ``) - .join("") - - const content = ` -
-
-

${attackerName} targets ${defenderName} with ${weaponName}!

-

Attack roll: ${attackRoll}

-
-
- - -
-
- ` - - 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]) => - `` - ).join("") - - const content = ` -
-
-

${attackerName} attacks ${defenderName} with ${weaponName}!

-

Attack roll: ${attackRoll}

-
-
- - -
-
- ` - - // 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 => - `` - ).join("") - - const content = ` -
-
-

${attackerName} attacks ${defenderName} with ${weaponName}!

-

Attack roll: ${attackRoll}

-
-
- - -
-
- ` - - // 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 => ``).join("") - const content = ` -
-
-

${actorName} currently has ${currentRoll}

-

Opposing ${sideLabel} roll: ${opposingRoll}

-
-
- - -
-
- ` - - 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 => ``).join("") - const content = ` -
-
-

${defenderName} uses a shield (not equipped)

-

Attack: ${attackRoll} — Current defense: ${defenseRoll}

-
-
- - -
-
- - -
-
- ` - - 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 = ` -
- -
- ` - } else if (data.attackRollType === "monster-attack") { - damageButton = ` -
- -
- ` - } 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 ` - ` - }).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")}} +
+ +
+{{else if (equals type "monster")}} +
+ +
+{{else if (equals type "spell")}} +
+ {{#each tiers}} + + {{/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 @@ +
{{{rollHTML}}}
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 @@ +
+
+ + {{titleText}} + {{badge}} +
+
+ {{#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}}

+
+
+ + +
+
+ + +
+
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}}

+
+
+ + +
+
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 @@ +
+

D30 DR Multiplier ×{{multiplier}}

+

Choose which DR types to multiply:

+ + + +
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}}

+
+
+ + +
+
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}}

+
+
+ + +
+
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}}

+
+
+ + +
+
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}}

+
+ + +
+
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 diceButtons}} + + {{/each}} +
+ +