import { SYSTEM } from "./config/system.mjs" // Map temporaire pour stocker les données d'attaque en attente de défense if (!globalThis.pendingDefenses) { globalThis.pendingDefenses = new Map() } 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, token) => { // 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((event) => { event.preventDefault(); let hpLoss = event.currentTarget.dataset.hpValue; if (token) { let tokenFull = canvas.tokens.placeables.find(t => t.id === token._id); console.log(tokenFull, token) let actor = tokenFull.actor; actor.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((event) => { event.preventDefault(); let hpGain = event.currentTarget.dataset.hpValue; if (token) { let tokenFull = canvas.tokens.placeables.find(t => t.id === token._id); console.log(tokenFull, token) let actor = tokenFull.actor; actor.applyDamage(Number(hpGain)); // Positive value to add HP $(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((event) => { event.preventDefault(); if (token) { let tokenFull = canvas.tokens.placeables.find(t => t.id === token._id); let actor = tokenFull.actor; const resource = event.currentTarget.dataset.resource; const amount = Number(event.currentTarget.dataset.amount); const current = Number(foundry.utils.getProperty(actor.system, `${resource}.current`)) || 0; const newValue = Math.max(0, current + amount); actor.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 handleSocketEvent(msg = {}) { console.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) actor.applyDamage(msg.damage) } break case "rollInitiative": if (msg.userId && msg.userId !== game.user.id) break actor = game.actors.get(msg.actorId) 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) 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" } actor.update({ "system.hp.wounds": wounds }) } } } 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 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" console.log(`[LF] showDefenseRequest | attackRollType=${attackRollType} isMonster=${isMonster} defender=${defender?.name}`) // Spell/miracle attacks use saving throws instead of weapon defense const isSpellAttack = attackRollType === "spell-attack" || attackRollType === "miracle-attack" if (isSpellAttack) { const savesConfig = isMonster ? SYSTEM.MONSTER_SAVES : SYSTEM.SAVES const combatSaves = ["will", "dodge", "toughness"] const savesHTML = Object.values(savesConfig) .filter(s => combatSaves.includes(s.id)) .map(s => ``) .join("") const content = `

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

Attack roll: ${attackRoll}

` const result = await foundry.applications.api.DialogV2.wait({ window: { title: "Saving Throw vs Spell" }, classes: ["lethalfantasy"], content, buttons: [ { action: "rollSave", 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.nextDefenseData = { attackerId, attackRoll, attackerName, defenderName, attackWeaponId, attackRollType, attackRollKey, attackD30result, attackD30message, attackRerollContext, attackNaturalRoll: msg.attackNaturalRoll, damageTier: msg.damageTier, defenderId: defender.id, defenderTokenId } if (isMonster) { defender.system.prepareMonsterRoll("save", result) } else { defender.prepareRoll("save", result) } } return } // Pour les monstres, récupérer les attaques activées if (isMonster) { const attacksSet = defender.system.attackMode === "ranged" ? defender.system.rangedAttacks : defender.system.attacks const enabledAttacks = Object.entries(attacksSet).filter(([key, attack]) => attack.enabled) if (enabledAttacks.length === 0) { ui.notifications.warn("No enabled attacks available for defense") return } // Créer le contenu du dialogue pour monstre let attacksHTML = enabledAttacks.map(([key, attack]) => `` ).join("") const content = `

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

Attack roll: ${attackRoll}

` // Afficher le dialogue const result = await foundry.applications.api.DialogV2.wait({ window: { title: msg.isRanged ? "Ranged Defense Roll" : "Defense Roll" }, classes: ["lethalfantasy"], content, buttons: [ { label: "Roll Defense", icon: "fa-solid fa-shield", callback: (event, button, dialog) => { const attackKey = button.form.elements.attackKey.value return attackKey }, }, ], rejectClose: false }) // Si l'utilisateur a validé, lancer le jet de défense if (result) { // Stocker temporairement les données pour le hook preCreateChatMessage game.lethalFantasy = game.lethalFantasy || {} game.lethalFantasy.nextDefenseData = { attackerId, attackRoll, attackerName, defenderName, attackWeaponId, attackRollType, attackRollKey, attackD30result, attackD30message, attackRerollContext, attackNaturalRoll: msg.attackNaturalRoll, damageTier: msg.damageTier, defenderId: defender.id, defenderTokenId, isRanged: msg.isRanged } defender.system.prepareMonsterRoll("monster-defense", result) } return } // Pour les personnages, récupérer les armes équipées // Si l'attaque est une attaque à distance, utiliser le dialogue de défense à distance if (msg.isRanged) { const { default: LethalFantasyRoll } = await import("./documents/roll.mjs") const roll = await LethalFantasyRoll.promptRangedDefense({ actorId: defender.id, actorName: defender.name, actorImage: defender.img, }) if (roll) { game.lethalFantasy = game.lethalFantasy || {} game.lethalFantasy.nextDefenseData = { attackerId, attackRoll, attackerName, defenderName, attackWeaponId, attackRollType, attackRollKey, attackD30result, attackD30message, attackRerollContext, damageTier: msg.damageTier, defenderId: defender.id, defenderTokenId, isRanged: true } await roll.toMessage({}, { messageMode: roll.options.rollMode }) } return } // Pour les personnages, récupérer les armes équipées const equippedWeapons = defender.items.filter(i => i.type === "weapon" && i.system.equipped === true ) if (equippedWeapons.length === 0) { ui.notifications.warn("No equipped weapons for defense") return } // Créer le contenu du dialogue pour personnage let weaponsHTML = equippedWeapons.map(w => `` ).join("") const content = `

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

Attack roll: ${attackRoll}

` // Afficher le dialogue const result = await foundry.applications.api.DialogV2.wait({ window: { title: "Defense Roll" }, classes: ["lethalfantasy"], content, buttons: [ { label: "Roll Defense", icon: "fa-solid fa-shield", callback: (event, button, dialog) => { const weaponId = button.form.elements.weaponId.value return weaponId }, }, ], rejectClose: false }) // Si l'utilisateur a validé, lancer le jet de défense if (result) { // Stocker temporairement les données pour le hook preCreateChatMessage game.lethalFantasy = game.lethalFantasy || {} game.lethalFantasy.nextDefenseData = { attackerId, attackRoll, attackerName, defenderName, attackWeaponId, attackRollType, attackRollKey, attackD30result, attackD30message, attackRerollContext, attackNaturalRoll: msg.attackNaturalRoll, damageTier: msg.damageTier, defenderId: defender.id, defenderTokenId, isRanged: msg.isRanged } console.log("Storing defense data for character:", defender.id) defender.prepareRoll("weapon-defense", result) } } /* -------------------------------------------- */ static hasD30Reroll(d30Message) { return d30Message?.type === "mulligan" } /* -------------------------------------------- */ /** * Process D30 bonus dice for attack or defense. * Rolls and applies bonus dice BEFORE grit/luck/shield decisions. * For `choice` type results (D30=20, 30), shows dialog to choose between bonus dice and special effect. * For `bonus_dice` type results (D30=27, 2, 3), auto-rolls the dice. * @param {Object|null} d30Message The D30 result object * @param {"attack"|"defense"} side Whether processing the attack or defense side * @param {number|null} naturalRoll The natural D20 roll (for special strike type detection) * @param {Object} actor The actor (for dice3d display) * @returns {Promise<{modifier: number, specialEffect: string|null, specialName: string|null}>} */ static async processD30BonusDice(d30Message, side, naturalRoll = null, actor = null) { if (!d30Message) return { modifier: 0, specialEffect: null, specialName: null } const validTargets = side === "attack" ? ["attack", "spell_attack"] : ["defense", "spell_defense"] // ── Simple bonus_dice type ── auto-roll if target matches if (d30Message.type === "bonus_dice") { if (!validTargets.includes(d30Message.target)) return { modifier: 0, specialEffect: null, specialName: null } const modifier = await this._rollD30BonusDie(d30Message.dice, actor) return { modifier, specialEffect: null, specialName: null } } // ── Choice type ── present all options to the player if (d30Message.type === "choice") { const buttons = d30Message.choices.map(c => { let label let icon if (c.type === "bonus_dice") { label = `Roll ${c.dice.toUpperCase()} and add to ${side}` icon = "fa-solid fa-dice" } else if (c.type === "special_strike") { label = this._buildSpecialLabel(c, naturalRoll) icon = "fa-solid fa-star" } else if (c.type === "special_defense") { label = this._buildSpecialLabel(c, naturalRoll) icon = "fa-solid fa-shield-halved" } else { label = c.type.replace(/_/g, " ").replace(/\b\w/g, l => l.toUpperCase()) icon = "fa-solid fa-question" } return { action: c.type, label, icon, callback: () => c } }) const choice = await foundry.applications.api.DialogV2.wait({ window: { title: "D30 Special — Choose Effect" }, classes: ["lethalfantasy"], content: `

D30 result: ${d30Message.description}

Choose how to use this result:

`, buttons, rejectClose: false }) if (!choice) return { modifier: 0, specialEffect: null, specialName: null } if (choice.type === "bonus_dice") { const modifier = await this._rollD30BonusDie(choice.dice, actor) return { modifier, specialEffect: null, specialName: null } } if (choice.type === "special_strike" || choice.type === "special_defense") { return { modifier: 0, specialEffect: "auto", specialName: this._buildSpecialName(choice, naturalRoll) } } // Non-standard choice (spell_calamity, etc.) — report it return { modifier: 0, specialEffect: "flag", specialName: choice.type } } // ── Combo type (bleed / internal injury) — flag for wound creation if (d30Message.type === "combo") { const hasBleed = d30Message.effects?.some(e => e.type === "bleed" || e.type === "internal_injury") if (hasBleed) { return { modifier: 0, specialEffect: "bleed", specialName: "Bleeding/Internal Injury" } } } // ── Damage multiplier type (2x/3x damage before DR) if (d30Message.type === "damage_multiplier") { return { modifier: 0, specialEffect: "damageMultiplier", specialName: `x${d30Message.multiplier} Damage`, multiplier: d30Message.multiplier } } // ── DR multiplier type (2x/3x DR including shield) if (d30Message.type === "dr_multiplier") { return { modifier: 0, specialEffect: "drMultiplier", specialName: `x${d30Message.multiplier} DR`, multiplier: d30Message.multiplier } } return { modifier: 0, specialEffect: null, specialName: null } } /* -------------------------------------------- */ /** * Roll a D30 bonus die and show with 3D dice if available. * @param {string} formula Dice formula (e.g. "D6", "D12", "D20E") * @param {Object} actor Actor for chat message speaker * @returns {Promise} The roll total */ static async _rollD30BonusDie(formula, actor) { const cleaned = formula.replace(/NE$/i, "").replace("E", "") const roll = new Roll(cleaned) await roll.evaluate() if (game?.dice3d) { await game.dice3d.showForRoll(roll, game.user, true) } await ChatMessage.create({ content: `

D30 bonus: rolled ${cleaned.toUpperCase()} = ${roll.total}

`, speaker: ChatMessage.getSpeaker({ actor }) }) return roll.total } /* -------------------------------------------- */ /** * Build a human-readable label for a special strike/defense choice in the D30 prompt. * @param {Object} specialChoice The choice object with type and options * @param {number|null} naturalRoll The natural D20 roll * @returns {string} Display label */ static _buildSpecialLabel(specialChoice, naturalRoll) { if (specialChoice.type === "special_strike") { if (specialChoice.options.includes("lethal")) { if (naturalRoll === 20) return "Lethal Strike (auto-hit)" if (naturalRoll >= 16 && naturalRoll <= 19) return "Vital Strike (auto-hit)" return "Lethal/Vital Strike (auto-hit)" } if (specialChoice.options.includes("vicious")) return "Vicious Strike (auto-hit)" return "Special Strike (auto-hit)" } if (specialChoice.type === "special_defense") { if (specialChoice.options.includes("perfect_spell")) return "Perfect Spell Defense (auto-block)" if (specialChoice.options.includes("flawless")) return "Flawless Defense (auto-block)" if (specialChoice.options.includes("legendary")) return "Legendary Defense (auto-block)" if (specialChoice.options.includes("perfect")) return "Perfect Defense (auto-block)" return "Special Defense (auto-block)" } return "Special Effect" } /* -------------------------------------------- */ /** * Build the special effect name based on the D30 result and natural roll. * @param {Object} specialChoice The choice object with type and options * @param {number|null} naturalRoll The natural D20 roll * @returns {string} The special effect name */ static _buildSpecialName(specialChoice, naturalRoll) { if (specialChoice.type === "special_strike") { if (specialChoice.options.includes("lethal")) { if (naturalRoll === 20) return "Lethal Strike" if (naturalRoll >= 16 && naturalRoll <= 19) return "Vital Strike" return "Lethal/Vital Strike" } if (specialChoice.options.includes("vicious")) return "Vicious Strike" return "Special Strike" } if (specialChoice.type === "special_defense") { if (specialChoice.options.includes("perfect_spell")) return "Perfect Spell Defense" if (specialChoice.options.includes("flawless")) return "Flawless Defense" if (specialChoice.options.includes("legendary")) return "Legendary Defense" if (specialChoice.options.includes("perfect")) return "Perfect Defense" return "Special Defense" } return "Special Effect" } /* -------------------------------------------- */ static getCombatBonusDiceChoices() { return ["1d4", "1d6", "1d8", "1d10", "1d12", "1d20", "1d20e"] } /* -------------------------------------------- */ static getShieldReactionData(actor) { if (!actor) return null if (actor.type === "monster") { const formula = actor.system.combat?.shieldDefenseDice const damageReduction = actor.getShieldDR() if (!formula || damageReduction <= 0) return null return { label: game.i18n.localize("LETHALFANTASY.Label.shieldDefenseDice"), formula, damageReduction } } const equippedShields = actor.items.filter(item => item.type === "shield" && item.system.equipped) if (equippedShields.length === 0) return null const shield = equippedShields[0] return { label: shield.name, formula: shield.system.defense, damageReduction: actor.getShieldDR(), shieldId: shield.id } } /* -------------------------------------------- */ static async promptCombatBonusDie(actorName, sideLabel, currentRoll, opposingRoll) { const choices = this.getCombatBonusDiceChoices() const optionsHtml = choices.map(choice => ``).join("") const content = `

${actorName} currently has ${currentRoll}

Opposing ${sideLabel} roll: ${opposingRoll}

` return await foundry.applications.api.DialogV2.wait({ window: { title: "Add Bonus Die" }, classes: ["lethalfantasy"], content, buttons: [ { action: "roll", label: "Roll Bonus Die", icon: "fa-solid fa-dice", callback: (event, button) => { const sel = button.form?.elements?.bonusDie ?? button.closest("form")?.elements?.bonusDie return sel?.value ?? choices[0] } }, { action: "cancel", label: "Cancel", icon: "fa-solid fa-xmark", callback: () => null } ], rejectClose: false }) } /* -------------------------------------------- */ /** * Prompt the GM or player to choose an ad-hoc shield dice and DR value. * Used when the defender has no pre-configured shield equipment. * @param {string} defenderName * @param {number} attackRoll * @param {number} defenseRoll * @returns {Promise<{formula: string, damageReduction: number}|null>} */ static async promptAdHocShield(defenderName, attackRoll, defenseRoll) { const choices = this.getCombatBonusDiceChoices() const optionsHtml = choices.map(c => ``).join("") const content = `

${defenderName} uses a shield (not equipped)

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

` const raw = await foundry.applications.api.DialogV2.wait({ window: { title: "Ad-hoc Shield Roll" }, classes: ["lethalfantasy"], content, buttons: [ { action: "roll", 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", label: "Cancel", icon: "fa-solid fa-xmark", callback: () => null } ], rejectClose: false }) return raw ?? null } /* -------------------------------------------- */ /** * Roll a bonus die formula, optionally showing Dice So Nice animation and posting a chat message. * @param {string} formula * @param {Actor} actor * @param {Function} [messageContent] * @returns {Promise} */ static async rollBonusDie(formula, actor, messageContent) { const roll = new Roll(formula) await roll.evaluate() if (game?.dice3d) { await game.dice3d.showForRoll(roll, game.user, true) } if (messageContent) { await ChatMessage.create({ content: messageContent(roll.total, formula), speaker: ChatMessage.getSpeaker({ actor }) }) } return roll.total } /* -------------------------------------------- */ static async rerollConfiguredRoll(rerollContext = {}) { const RollClass = CONFIG.Dice.rolls.find(r => r.name === "LethalFantasyRoll") if (typeof RollClass?.prompt !== "function") { ui.notifications.error("Lethal Fantasy roll class not available for reroll") return null } return await RollClass.prompt({ ...foundry.utils.duplicate(rerollContext), rollContext: foundry.utils.duplicate(rerollContext.rollContext || {}), forceNoD30: true, hasTarget: false, target: false }) } /* -------------------------------------------- */ static async offerGritLuckBonus(defender, attackRoll, currentDefenseRoll, attackerName, defenderName) { let totalBonus = 0 let keepOffering = true while (keepOffering && currentDefenseRoll + totalBonus < attackRoll) { const currentGrit = defender.system.grit.current const currentLuck = defender.system.luck.current // Si plus de points disponibles, sortir if (currentGrit <= 0 && currentLuck <= 0) { break } const buttons = [] if (currentGrit > 0) { buttons.push({ action: "grit", 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: "continue", label: "Continue (no bonus)", icon: "fa-solid fa-forward", callback: () => "continue" }) const content = `

${attackerName} rolled ${attackRoll}

${defenderName} currently has ${currentDefenseRoll + totalBonus}

${totalBonus > 0 ? `

Bonus already added: +${totalBonus}

` : ''}

You are losing! Spend Grit or Luck to add 1D6 to your defense?

If you intend to use a shield, you must spend Grit or Luck first — the shield roll comes after.

` const choice = await foundry.applications.api.DialogV2.wait({ window: { title: "Defend with Grit or Luck" }, classes: ["lethalfantasy"], content, buttons, rejectClose: false }) if (!choice || choice === "continue") { keepOffering = false break } // Lancer 1D6 const bonusRoll = new Roll("1d6") await bonusRoll.evaluate() if (game?.dice3d) { await game.dice3d.showForRoll(bonusRoll, game.user, true) } totalBonus += bonusRoll.total // Déduire le point de Grit ou Luck if (choice === "grit") { await defender.update({ "system.grit.current": currentGrit - 1 }) await ChatMessage.create({ content: `

${defenderName} spends 1 Grit and rolls ${bonusRoll.total}! (Total defense bonus: +${totalBonus})

`, speaker: ChatMessage.getSpeaker({ actor: defender }) }) } else if (choice === "luck") { await defender.update({ "system.luck.current": currentLuck - 1 }) await ChatMessage.create({ content: `

${defenderName} spends 1 Luck and rolls ${bonusRoll.total}! (Total defense bonus: +${totalBonus})

`, speaker: ChatMessage.getSpeaker({ actor: defender }) }) } } return totalBonus } /* -------------------------------------------- */ static async offerAttackerGritBonus(attacker, currentAttackRoll, defenseRoll, attackerName, defenderName) { let totalBonus = 0 let keepOffering = true while (keepOffering && currentAttackRoll + totalBonus <= defenseRoll) { const currentGrit = attacker.system.grit.current // Si plus de points de Grit disponibles, sortir if (currentGrit <= 0) { break } const buttons = [ { action: "grit", label: `Spend 1 Grit (+1D6) [${currentGrit} left]`, icon: "fa-solid fa-fist-raised", callback: () => "grit" }, { action: "continue", label: "Continue (no bonus)", icon: "fa-solid fa-forward", callback: () => "continue" } ] const content = `

${attackerName} currently has ${currentAttackRoll + totalBonus}

${defenderName} rolled ${defenseRoll}

${totalBonus > 0 ? `

Bonus already added: +${totalBonus}

` : ''}

You are losing! Spend Grit to add 1D6 to your attack?

` const choice = await foundry.applications.api.DialogV2.wait({ window: { title: "Attack with Grit" }, classes: ["lethalfantasy"], content, buttons, rejectClose: false }) if (!choice || choice === "continue") { keepOffering = false break } // Lancer 1D6 const bonusRoll = new Roll("1d6") await bonusRoll.evaluate() if (game?.dice3d) { await game.dice3d.showForRoll(bonusRoll, game.user, true) } totalBonus += bonusRoll.total // Déduire le point de Grit await attacker.update({ "system.grit.current": currentGrit - 1 }) await ChatMessage.create({ content: `

${attackerName} spends 1 Grit and rolls ${bonusRoll.total}! (Total attack bonus: +${totalBonus})

`, speaker: ChatMessage.getSpeaker({ actor: attacker }) }) } return totalBonus } /* -------------------------------------------- */ static async compareAttackDefense(data) { console.log("compareAttackDefense called with:", data) const outcome = data.outcome || (data.attackRoll > data.defenseRoll ? "hit" : "miss") const isAttackWin = outcome !== "miss" console.log("isAttackWin:", isAttackWin, "attackRoll:", data.attackRoll, "defenseRoll:", data.defenseRoll) let damageButton = "" if (isAttackWin && (data.attackWeaponId || data.attackRollKey)) { console.log("Creating damage button. defenderId:", data.defenderId) // Déterminer le type de dégâts à lancer if (data.attackRollType === "weapon-attack") { damageButton = `
` } else if (data.attackRollType === "monster-attack") { damageButton = `
` } else if (data.attackRollType === "spell-attack" || data.attackRollType === "miracle-attack") { const attacker = game.actors.get(data.attackerId) const spell = attacker?.items.get(data.attackWeaponId || data.attackRollKey) const chosenTier = data.damageTier || "standard" const allTiers = [ { id: "standard", formula: spell?.system?.damageDice, label: "Standard" }, { id: "overpowered", formula: spell?.system?.damageDiceOverpowered, label: "Overpowered" }, { id: "overpowered2", formula: spell?.system?.damageDiceOverpowered2, label: "Overpowered 2" }, ] const tiers = allTiers.filter(t => t.id === chosenTier && t.formula) if (tiers.length) { const buttons = tiers.map(t => { const escapedFormula = Handlebars.escapeExpression(t.formula) return ` ` }).join("") damageButton = `
${buttons}
` } } } const resultMessage = `

Combat Result

Attacker
${data.attackerName}
${data.attackRoll}
VS
Defender
${data.defenderName}
${data.defenseRoll}
${outcome === "shielded-hit" ? ` ${data.defenderName} has blocked with shield — apply armor DR + shield DR ${data.shieldDamageReduction || 0}.` : isAttackWin ? ` ${data.attackerName} hits ${data.defenderName}!` : ` ${data.defenderName} avoided the attack!` }
${damageButton}
` console.log("Creating combat result message...") await ChatMessage.create({ content: resultMessage, speaker: { alias: "Combat System" } }) console.log("Combat result message created!") } static registerHandlebarsHelpers() { Handlebars.registerHelper('isNull', function (val) { return val == null; }); Handlebars.registerHelper('match', function (val, search) { if (val && search) { return val?.match(search); } return false }); Handlebars.registerHelper('exists', function (val) { return val != null && val !== undefined; }); Handlebars.registerHelper('isEmpty', function (list) { if (list) return list.length === 0; else return false; }); Handlebars.registerHelper('notEmpty', function (list) { return list.length > 0; }); Handlebars.registerHelper('isNegativeOrNull', function (val) { return val <= 0; }); Handlebars.registerHelper('isNegative', function (val) { return val < 0; }); Handlebars.registerHelper('isPositive', function (val) { return val > 0; }); Handlebars.registerHelper('equals', function (val1, val2) { return val1 === val2; }); Handlebars.registerHelper('neq', function (val1, val2) { return val1 !== val2; }); Handlebars.registerHelper('gt', function (val1, val2) { return val1 > val2; }) Handlebars.registerHelper('lt', function (val1, val2) { return val1 < val2; }) Handlebars.registerHelper('gte', function (val1, val2) { return val1 >= val2; }) Handlebars.registerHelper('lte', function (val1, val2) { return val1 <= val2; }) Handlebars.registerHelper('and', function (val1, val2) { return val1 && val2; }) Handlebars.registerHelper('or', function (val1, val2) { return val1 || val2; }) Handlebars.registerHelper('or3', function (val1, val2, val3) { return val1 || val2 || val3; }) Handlebars.registerHelper('for', function (from, to, incr, block) { let accum = ''; for (let i = from; i < to; i += incr) accum += block.fn(i); return accum; }) Handlebars.registerHelper('not', function (cond) { return !cond; }) Handlebars.registerHelper('count', function (list) { return list.length; }) Handlebars.registerHelper('countKeys', function (obj) { return Object.keys(obj).length; }) Handlebars.registerHelper('isEnabled', function (configKey) { return game.settings.get("bol", configKey); }) Handlebars.registerHelper('split', function (str, separator, keep) { return str.split(separator)[keep]; }) // If you need to add Handlebars helpers, here are a few useful examples: Handlebars.registerHelper('concat', function () { let outStr = ''; for (let arg in arguments) { if (typeof arguments[arg] != 'object') { outStr += arguments[arg]; } } return outStr; }) Handlebars.registerHelper('add', function (a, b) { return parseInt(a) + parseInt(b); }); Handlebars.registerHelper('mul', function (a, b) { return parseInt(a) * parseInt(b); }) Handlebars.registerHelper('sub', function (a, b) { return parseInt(a) - parseInt(b); }) Handlebars.registerHelper('abbrev2', function (a) { return a.substring(0, 2); }) Handlebars.registerHelper('abbrev3', function (a) { return a.substring(0, 3); }) Handlebars.registerHelper('valueAtIndex', function (arr, idx) { return arr[idx]; }) Handlebars.registerHelper('includesKey', function (items, type, key) { return items.filter(i => i.type === type).map(i => i.system.key).includes(key); }) Handlebars.registerHelper('includes', function (array, val) { return array.includes(val); }) Handlebars.registerHelper('eval', function (expr) { return eval(expr); }) Handlebars.registerHelper('isOwnerOrGM', function (actor) { console.log("Testing actor", actor.isOwner, game.userId) return actor.isOwner || game.isGM; }) Handlebars.registerHelper('upperCase', function (text) { if (typeof text !== 'string') return text return text.toUpperCase() }) Handlebars.registerHelper('upperFirst', function (text) { if (typeof text !== 'string') return text return text.charAt(0).toUpperCase() + text.slice(1) }) Handlebars.registerHelper('upperFirstOnly', function (text) { if (typeof text !== 'string') return text return text.charAt(0).toUpperCase() }) // Handle v12 removal of this helper Handlebars.registerHelper('select', function (selected, options) { const escapedValue = RegExp.escape(Handlebars.escapeExpression(selected)); const rgx = new RegExp(' value=[\"\']' + escapedValue + '[\"\']'); const html = options.fn(this); return html.replace(rgx, "$& selected"); }); } static getLethargyDice(level) { for (let s of SYSTEM.SPELL_LETHARGY_DICE) { if (Number(level) <= s.maxLevel) { return s.dice } } } /* -------------------------------------------- */ static async applyDamage(message, event) { // Récupérer les données du message let combatantId = event.currentTarget.dataset.combatantId if (!combatantId) { ui.notifications.error("No combatant selected") return } // Try to find the target: first as a combat combatant, then as a scene token let targetActor = null if (game.combat) { const combatant = game.combat.combatants.get(combatantId) if (combatant) { targetActor = combatant.token?.actor || game.actors.get(combatant.actorId) } } if (!targetActor) { // Fall back to scene token lookup (non-combat tokens use tokenId as their combatantId) const token = canvas.tokens?.placeables?.find(t => t.id === combatantId) targetActor = token?.actor } if (!targetActor) { ui.notifications.error("Target actor not found") return } // Récupérer les données de dégâts du message // Use options.rollTotal (includes weapon modifier bonus) rather than roll.total (dice formula only) let damageTotal = message.rolls[0]?.options?.rollTotal ?? message.rolls[0]?.total ?? 0 let weaponName = message.rolls[0]?.options?.rollName || "Unknown Weapon" // Calculer les DR let armorDR = targetActor.computeDamageReduction() || 0 let shieldDR = targetActor.getShieldDR() || 0 let totalDR = armorDR + shieldDR // Créer le dialogue const content = await foundry.applications.handlebars.renderTemplate( "systems/fvtt-lethal-fantasy/templates/apply-damage-dialog.hbs", { targetName: targetActor.name, weaponName: weaponName, damageTotal: damageTotal, armorDR: armorDR, shieldDR: shieldDR, totalDR: totalDR, damageNoDR: damageTotal, damageWithArmor: Math.max(0, damageTotal - armorDR), damageWithAll: Math.max(0, damageTotal - totalDR) } ) const result = await foundry.applications.api.DialogV2.wait({ window: { title: "Apply Damage" }, classes: ["lethalfantasy"], position: { width: 280 }, content, buttons: [ { action: "noDR", label: "No DR", callback: () => ({ drType: "none", damage: damageTotal }) }, { action: "armorDR", label: "With Armor DR", callback: () => ({ drType: "armor", damage: Math.max(0, damageTotal - armorDR) }) }, { action: "allDR", label: "With Armor + Shield DR", callback: () => ({ drType: "all", damage: Math.max(0, damageTotal - totalDR) }) }, { action: "cancel", 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 } ) ChatMessage.create({ user: game.user.id, speaker: { alias: targetActor.name }, mode: "gm", content: messageContent }) } } }