From 46fa2d15a3d9416b214db2d8be6fc4f73c72f8f0 Mon Sep 17 00:00:00 2001 From: LeRatierBretonnier Date: Thu, 11 Jun 2026 22:41:54 +0200 Subject: [PATCH] fix: allow defender to react when attacker boosts past defense via cross-client socket --- lethal-fantasy.mjs | 30 +++++++- module/utils.mjs | 179 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+), 1 deletion(-) diff --git a/lethal-fantasy.mjs b/lethal-fantasy.mjs index 4c217df..3b9e1c8 100644 --- a/lethal-fantasy.mjs +++ b/lethal-fantasy.mjs @@ -1002,9 +1002,37 @@ Hooks.on("createChatMessage", async (message) => { } } } + + // If attacker boosted past defense, let the defender react + if (attackerHandledBonus && defenseRoll < attackRollFinal && defender) { + const defenderOwner = game.users.find(u => u.active && !u.isGM && defender.testUserPermission(u, "OWNER")) + if (defenderOwner && defenderOwner.id !== game.user.id) { + // Cross-client: send socket to defender's client + const sData = LethalFantasyUtils.getShieldReactionData(defender) + game.socket.emit(`system.${SYSTEM.id}`, { + type: "attackBoosted", + userId: defenderOwner.id, + attackerName, attackerId, defenderName, defenderId, defenderTokenId, + attackRollFinal, defenseRoll, attackWeaponId, attackRollType, attackRollKey, + shieldDamageReduction: shieldBlocked ? shieldReaction?.damageReduction ?? 0 : 0, + d30Bleed: d30Bleed ? "true" : "", + d30DamageMultiplier, d30DrMultiplier, + damageTier: damageTier || "standard", + attackD30message, + hasShield: !!sData, + shieldLabel: sData?.label || "", + shieldFormula: sData?.formula || "", + shieldDr: sData?.damageReduction || 0, + canAdHocShield: !sData, + }) + return // Comparison message created by defender's client + } + // Single-client (GM controls both): restart so defender loop can run + mulliganRestart = true + } } while (mulliganRestart) - const shieldDamageReduction = shieldBlocked ? shieldReaction.damageReduction : 0 + const shieldDamageReduction = shieldBlocked ? shieldReaction?.damageReduction ?? 0 : 0 const outcome = shieldBlocked ? "shielded-hit" : (attackRollFinal > defenseRoll ? "hit" : "miss") // Créer le message de comparaison - uniquement par le client qui a géré le dernier bonus diff --git a/module/utils.mjs b/module/utils.mjs index 5f8bee3..6baae2f 100644 --- a/module/utils.mjs +++ b/module/utils.mjs @@ -189,6 +189,11 @@ export default class LethalFantasyUtils { } } break + case "attackBoosted": + if (msg.userId === game.user.id) { + LethalFantasyUtils.handleAttackBoosted(msg) + } + break } } @@ -226,6 +231,180 @@ export default class LethalFantasyUtils { }) } + /* -------------------------------------------- */ + static async handleAttackBoosted(msg) { + const { + attackerName, attackerId, defenderName, defenderId, defenderTokenId, + attackRollFinal, defenseRoll, attackWeaponId, attackRollType, attackRollKey, + shieldDamageReduction: initialShieldDR, + d30Bleed, d30DamageMultiplier, d30DrMultiplier, + damageTier, attackD30message, + 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 + + // Show the defense reaction dialog + if (defender && 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", + label: `Spend 1 Grit (+1D6) [${currentGrit} left]`, + icon: "fa-solid fa-fist-raised", + callback: () => "grit" + }) + } + + if (currentLuck > 0) { + buttons.push({ + action: "luck", + label: `Spend 1 Luck (+1D6) [${currentLuck} left]`, + icon: "fa-solid fa-clover", + callback: () => "luck" + }) + } + + buttons.push({ + action: "bonusDie", + label: "Add bonus die", + icon: "fa-solid fa-dice", + callback: () => "bonusDie" + }) + + if (canShieldReact) { + buttons.push({ + action: "shieldReact", + label: `Roll shield (${shieldLabel})`, + icon: "fa-solid fa-shield", + callback: () => "shieldReact" + }) + } else if (canAdHoc) { + buttons.push({ + action: "adHocShield", + label: "Roll ad-hoc shield (choose dice + DR)", + icon: "fa-solid fa-shield-halved", + callback: () => "adHocShield" + }) + } + + buttons.push({ + action: "continue", + label: "Continue (no defense bonus)", + icon: "fa-solid fa-forward", + callback: () => "continue" + }) + + const choice = await foundry.applications.api.DialogV2.wait({ + window: { title: "Defense reactions — attack boosted" }, + classes: ["lethalfantasy"], + content: ` +
+
+

${attackerName} boosted attack to ${attackRollFinal}

+

${defenderName} currently has ${updatedDefenseRoll}

+
+

The attack was boosted! Choose how to improve the defense.

+
+ `, + buttons, + rejectClose: false + }) + + if (choice === "grit") { + const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender, + total => `

${defenderName} spends 1 Grit and rolls ${total} for defense.

`) + updatedDefenseRoll += bonusRoll + await defender.update({ "system.grit.current": currentGrit - 1 }) + } else if (choice === "luck") { + const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender, + total => `

${defenderName} spends 1 Luck and rolls ${total} for defense.

`) + updatedDefenseRoll += bonusRoll + await defender.update({ "system.luck.current": currentLuck - 1 }) + } else if (choice === "bonusDie") { + const bonusDie = await LethalFantasyUtils.promptCombatBonusDie(defenderName, "attack", updatedDefenseRoll, attackRollFinal) + if (bonusDie) { + const bonusRoll = await LethalFantasyUtils.rollBonusDie(bonusDie, defender, + (total, formula) => `

${defenderName} adds ${formula.toUpperCase()} and rolls ${total} for defense.

`) + updatedDefenseRoll += bonusRoll + } + } else if (choice === "shieldReact" && canShieldReact) { + const shieldBonus = await LethalFantasyUtils.rollBonusDie(shieldFormula, defender) + const newDefenseTotal = updatedDefenseRoll + shieldBonus + updatedDefenseRoll = newDefenseTotal + canShieldReact = false + if (newDefenseTotal >= attackRollFinal) { + shieldBlocked = true + shieldReaction = { damageReduction: shieldDr, label: shieldLabel, bonus: shieldBonus } + await ChatMessage.create({ + content: `

${defenderName} rolls ${shieldLabel} and adds ${shieldBonus} to defense (${newDefenseTotal} ≥ ${attackRollFinal}). Shield blocked the attack! Both armor DR and shield DR ${shieldDr} will apply to damage.

`, + speaker: ChatMessage.getSpeaker({ actor: defender }) + }) + } else { + await ChatMessage.create({ + content: `

${defenderName} rolls ${shieldLabel} and adds ${shieldBonus} to defense (${newDefenseTotal} < ${attackRollFinal}). Shield did not block — normal hit, armor DR only.

`, + speaker: ChatMessage.getSpeaker({ actor: defender }) + }) + } + } else if (choice === "adHocShield" && canAdHoc) { + const adHoc = await LethalFantasyUtils.promptAdHocShield(defenderName, attackRollFinal, updatedDefenseRoll) + if (adHoc) { + const shieldBonus = await LethalFantasyUtils.rollBonusDie(adHoc.formula, defender) + const newDefenseTotal = updatedDefenseRoll + shieldBonus + updatedDefenseRoll = newDefenseTotal + canShieldReact = false + canAdHoc = false + if (newDefenseTotal >= attackRollFinal) { + shieldBlocked = true + shieldReaction = { damageReduction: adHoc.damageReduction, label: `${adHoc.formula.toUpperCase()} shield`, bonus: shieldBonus } + await ChatMessage.create({ + content: `

${defenderName} rolls ${adHoc.formula.toUpperCase()} shield and adds ${shieldBonus} to defense (${newDefenseTotal} ≥ ${attackRollFinal}). Shield blocked the attack! Both armor DR and shield DR ${adHoc.damageReduction} will apply to damage.

`, + speaker: ChatMessage.getSpeaker({ actor: defender }) + }) + } else { + await ChatMessage.create({ + content: `

${defenderName} rolls ${adHoc.formula.toUpperCase()} shield and adds ${shieldBonus} to defense (${newDefenseTotal} < ${attackRollFinal}). Shield did not block — normal hit, armor DR only.

`, + speaker: ChatMessage.getSpeaker({ actor: defender }) + }) + } + } + } + } + + const finalShieldDR = shieldBlocked ? (shieldReaction?.damageReduction || 0) : 0 + const outcome = shieldBlocked ? "shielded-hit" : (updatedDefenseRoll > attackRollFinal ? "miss" : "hit") + + await LethalFantasyUtils.compareAttackDefense({ + attackerName, + attackerId, + attackRoll: attackRollFinal, + attackWeaponId, + attackRollType, + attackRollKey, + defenderName, + defenderId, + defenderTokenId, + defenseRoll: updatedDefenseRoll, + outcome, + shieldDamageReduction: finalShieldDR, + d30Bleed: d30Bleed || "", + d30DamageMultiplier: d30DamageMultiplier || 1, + d30DrMultiplier: d30DrMultiplier || 1, + damageTier: damageTier || "standard", + attackD30message + }) + } + /* -------------------------------------------- */ static async showDefenseRequest(msg) { const attackerName = msg.attackerName