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) // When a GM-owned attacker has D30 data, the D30 bonus is only applied on the GM's // client. The defender must wait for the 'attackBoosted' socket to get the updated // attack roll instead of processing the original unboosted value via the hook. const d30PendingFromGM = attackD30message && attacker && !attackerHasNonGMOwner && !isPrimaryController(attacker) // Mirror side: on the GM's client, this socket MUST always be sent when a GM-owned // attacker has D30 data, even if the bonus was 0, because the defender can't know // the outcome without it. const d30RequiresSocket = attackD30message && attacker && !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 changed the attack in a way that needs cross-client sync let d30ChangedAttack = 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, or when D30 bonus is pending from GM — // the socket handler (handleAttackBoosted) will show the defense dialog and // create the comparison message with the updated attack roll. if (defender && defenseRoll < attackRollFinal && isPrimaryController(defender) && !isSpellOrMiracle && !attackerIsCrossClient && !d30PendingFromGM) { 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 // Track whether D30 actually changed the attack value (for cross-client sync) d30ChangedAttack = (attackRollFinal !== preD30AttackRoll) // 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 || d30RequiresSocket) { 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 // (either via Grit/bonus die in the attack reaction loop, or via D30 bonus) if (defenseRoll < attackRollFinal && (attackerHandledBonus || d30ChangedAttack)) { 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) // 4. When D30 is pending from GM, the player's client must wait for the socket // handler — the hook-based values are stale (D30 bonus not applied). const shouldCreateMessage = !d30PendingFromGM && ( 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") }) })