import { SYSTEM } from "../config/system.mjs" import { log } from "./helpers.mjs" import { processD30BonusDice, hasD30Reroll } 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, defenseRerollContext, 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 === "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 shieldData = canShieldReact ? { label: shieldLabel, formula: shieldFormula, damageReduction: shieldDr } : null const buttons = buildDefenseReactionButtons(defender, { canRerollDefense: hasD30Reroll(defenseD30message), shieldData, canShieldReact, canAdHocShield: canAdHoc }) 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: defenseD30message || 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": Math.max(0, (Number(defender.system?.grit?.current) || 0) - 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": Math.max(0, (Number(defender.system?.luck?.current) || 0) - 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 === "rerollDefense" && defenseRerollContext) { const oldDefenseRoll = updatedDefenseRoll const reroll = await rerollConfiguredRoll(defenseRerollContext) if (!reroll) continue updatedDefenseRoll = reroll.options?.rollTotal || reroll.total || oldDefenseRoll let newD30message = reroll.options?.D30message || null const mulliganContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", { type: "mulligan", actorName: defenderName, side: "defense", oldRoll: oldDefenseRoll, newRoll: updatedDefenseRoll, diceResults: reroll.options?.diceResults || [], D30result: reroll.options?.D30result, D30message: newD30message }) await ChatMessage.create({content: mulliganContent, speaker: ChatMessage.getSpeaker({actor: defender})}) // Process new D30 bonus dice from the reroll if (newD30message) { defenseD30message = newD30message const d30Result = await processD30BonusDice(defenseD30message, "defense", null, defender, true) if (d30Result.modifier) { updatedDefenseRoll += d30Result.modifier if (d30Result.modifier > 0) { const rmContent = 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: rmContent, speaker: ChatMessage.getSpeaker({actor: defender})}) } } if (d30Result.specialEffect === "flag") { const rmContent = 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: rmContent, speaker: ChatMessage.getSpeaker({actor: defender})}) } if (d30Result.specialEffect === "drMultiplier") { const rmContent = 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: rmContent, speaker: ChatMessage.getSpeaker({actor: defender})}) } } continue } 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" const _storeNextDefenseData = (opts = {}) => { game.lethalFantasy = game.lethalFantasy || {} game.lethalFantasy.nextDefenseData = { attackerId, attackRoll, attackerName, defenderName, attackWeaponId, attackRollType, attackRollKey, attackD30result, attackD30message, attackRerollContext, damageTier: msg.damageTier, defenderId: defender.id, defenderTokenId, ...(msg.attackNaturalRoll !== undefined && { attackNaturalRoll: msg.attackNaturalRoll }), ...(opts.isRanged !== undefined && { isRanged: opts.isRanged }) } } 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 _storeNextDefenseData() 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) { _storeNextDefenseData({ 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) { _storeNextDefenseData({ 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) { _storeNextDefenseData({ isRanged: msg.isRanged }) log("Storing defense data for character:", defender.id) await defender.prepareRoll("weapon-defense", result) } } export function buildDefenseReactionButtons(defender, { canRerollDefense = false, shieldData = null, canShieldReact = false, canAdHocShield = false } = {}) { 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 (canRerollDefense) { buttons.push({ action: "rerollDefense", type: "button", label: "Re-roll defense (Mulligan)", icon: "fa-solid fa-rotate-right", callback: () => "rerollDefense" }) } if (canShieldReact && shieldData) { buttons.push({ action: "shieldReact", type: "button", label: `Roll shield (${shieldData.label})`, icon: "fa-solid fa-shield", callback: () => "shieldReact" }) } else if (canAdHocShield) { 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" }) return buttons } 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 }) } }