import { SYSTEM } from "./config/system.mjs" export function log(...args) { if (game?.settings?.get(game.system.id, "debug")) { console.log(...args) } } 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.
${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}
${attackerName} attacks ${defenderName} with ${weaponName}!
Attack roll: ${attackRoll}
${attackerName} attacks ${defenderName} with ${weaponName}!
Attack roll: ${attackRoll}
D30 result: ${d30Message.description}
Choose how to use this result:
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}
${defenderName} uses a shield (not equipped)
Attack: ${attackRoll} — Current defense: ${defenseRoll}
${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.
${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?
${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 = `