From f47f2906a414276a7c4ca437877a08b23d90496b Mon Sep 17 00:00:00 2001 From: LeRatierBretonnier Date: Mon, 17 Aug 2026 20:12:12 +0200 Subject: [PATCH] fix: D30 special effects breaking combat comparison, map auto-pan, dead targets - Fix handleAttackBoosted guard: replaced attackerIsCrossClient check with defenderIsMine so the socket handler runs whenever the current user controls the defender. Previously, when a GM-controlled monster attacked with a D30 special result (damage multiplier, bleed, DR multiplier, mulligan), the createChatMessage hook was suppressed (d30PendingFromGM) but handleAttackBoosted returned early because the attacker wasn't a cross-client PC, so nobody created the comparison message. - Remove map auto-pan on target/damage button hover (mouseenter/mouseleave handlers on .request-defense-btn and .apply-wounds-btn). - Filter defeated combatants and dead monster tokens from the target selection list. Characters at 0 HP are still selectable (death saves). - Restore 3D dice animation for damage rolls (lethal-fantasy.mjs, character-sheet.mjs). - Allow negative bonuses on weapon items (damaged weapons). - Allow negative HP for characters (death saving throws). - Fix D30Roll.convertToInternalType guard to warn on missing weapon regardless of isRanged flag. - Fix ranged weapon loading comparison (<= to <). - Add isRangedAttack to rollTarget for consistent ranged detection. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- module/documents/roll-base.mjs | 5 ++++- module/hooks/chat-reaction.mjs | 26 -------------------------- module/utils/combat.mjs | 18 ++++++++++-------- 3 files changed, 14 insertions(+), 35 deletions(-) diff --git a/module/documents/roll-base.mjs b/module/documents/roll-base.mjs index 8463a54..a77396d 100644 --- a/module/documents/roll-base.mjs +++ b/module/documents/roll-base.mjs @@ -163,7 +163,7 @@ export default class LethalFantasyRoll extends Roll { // D'abord, ajouter les combattants du combat actif if (game?.combat?.combatants) { for (let c of game.combat.combatants) { - if (c.actorId !== this.actorId) { + if (c.actorId !== this.actorId && !c.isDefeated) { combatants.push({ id: c.id, name: c.name, tokenId: c.token.id }) } } @@ -174,6 +174,9 @@ export default class LethalFantasyRoll extends Roll { const existingTokenIds = new Set(combatants.map(c => c.tokenId)) for (let token of canvas.scene.tokens) { if (token.actorId !== this.actorId && !existingTokenIds.has(token.id)) { + // Skip dead tokens: monsters at 0 HP are dead; characters use the defeated flag + const tokenActor = game.actors.get(token.actorId) + if (tokenActor?.type === "monster" && (Number(tokenActor?.system?.hp?.value) || 0) <= 0) continue combatants.push({ id: token.id, name: token.name, diff --git a/module/hooks/chat-reaction.mjs b/module/hooks/chat-reaction.mjs index 46b3fa0..f79ec35 100644 --- a/module/hooks/chat-reaction.mjs +++ b/module/hooks/chat-reaction.mjs @@ -33,38 +33,12 @@ Hooks.on("renderChatMessageHTML", (message, html, data) => { } for (const btn of html.querySelectorAll(".apply-wounds-btn")) { - btn.addEventListener("mouseenter", () => { - const combatantId = btn.dataset.combatantId - if (combatantId && game.combat) { - const combatant = game.combat.combatants.get(combatantId) - if (combatant?.token) { - const token = canvas.tokens.get(combatant.token.id) - if (token) { - token.control({ releaseOthers: true }) - canvas.animatePan(token.center) - } - } - } - }) - btn.addEventListener("mouseleave", () => canvas.tokens.releaseAll()) btn.addEventListener("click", event => LethalFantasyUtils.applyDamage(message, event)) } } // Gestion du survol et du clic sur les boutons de défense for (const btn of html.querySelectorAll(".request-defense-btn")) { - btn.addEventListener("mouseenter", () => { - const tokenId = btn.dataset.tokenId - if (tokenId) { - const token = canvas.tokens.get(tokenId) - if (token) { - token.control({ releaseOthers: true }) - canvas.animatePan(token.center) - } - } - }) - btn.addEventListener("mouseleave", () => canvas.tokens.releaseAll()) - // Gestionnaire pour les boutons de demande de défense btn.addEventListener("click", async event => { event.preventDefault() diff --git a/module/utils/combat.mjs b/module/utils/combat.mjs index 48104b2..7eb476e 100644 --- a/module/utils/combat.mjs +++ b/module/utils/combat.mjs @@ -108,20 +108,22 @@ export async function handleAttackBoosted(msg) { const attacker = game.actors.get(attackerId) if (!defender) return - // When the attacker is GM-owned (not a PC on another client), the createChatMessage - // hook on the defender's client already handles everything: D30 bonus dice, defense - // reaction dialog (grit/luck/shield/mulligan), and the comparison message. Running - // handleAttackBoosted too would double-roll all dice and show duplicate dialogs. - // Only proceed when the hook is suppressed (attacker is a PC on another client). - const attackerHasNonGMOwner = attacker && game.users.some(u => u.active && !u.isGM && attacker.testUserPermission(u, "OWNER")) + // The createChatMessage hook is suppressed on this client in two cases: + // 1. Attacker is a PC on another client — the hook's defense reaction loop + // and shouldCreateMessage are both skipped. + // 2. Attacker is GM-controlled with D30 — the hook skips everything and + // waits for this socket handler. + // In both cases, this handler must run to show the defense dialog and create + // the comparison message. The common condition: the current user is the + // defender's primary controller. const _isPrimaryController = actor => { if (!actor) return false const activePlayerOwners = game.users.filter(u => u.active && !u.isGM && actor.testUserPermission(u, "OWNER")) if (activePlayerOwners.length > 0) return activePlayerOwners[0].id === game.user.id return game.user.isGM } - const attackerIsCrossClient = attackerHasNonGMOwner && !_isPrimaryController(attacker) - if (!attackerIsCrossClient) return + const defenderIsMine = _isPrimaryController(defender) + if (!defenderIsMine) return let updatedDefenseRoll = defenseRoll let shieldBlocked = false