From 96e5bd5b4d9a5c04a35de3f1b33d5ae0ce0eeb8b Mon Sep 17 00:00:00 2001 From: LeRatierBretonnier Date: Fri, 3 Jul 2026 22:39:10 +0200 Subject: [PATCH] fix: pre-process D30 attack bonus at source for cross-client agreement D30 bonus dice now rolled at defense request time, not in defense handler. Sends boosted attackRoll + d30AttackEffects in socket/nextDefenseData so both clients agree on the value. Adds stale flag for mulligan rerolls to bypass stale pre-computed effects and process the new D30 fresh. --- AGENTS.md | 6 ++++ module/hooks/chat-reaction.mjs | 54 +++++++++++++++++++++++++++------- module/utils/combat.mjs | 2 ++ 3 files changed, 52 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7270861..4fd3223 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,12 @@ Fix Grit/Luck defense reaction dialog UX (stacking dialogs, multiple clicks, rev - **Import `hasD30Reroll` added** to `utils/combat.mjs` - **`bleed` top-level type handler added** to `processD30BonusDice` in `d30.mjs:79-81` — returns `specialEffect: "bleed"` same as combo path, so ranged attack bleed (values 5,10,15) creates reaction message and sets damage button bleed flag. +### Pass 7 — Cross-Client Attack D30 Pre-Processing +- **BUG FIX: D30 attack bonus processed at source (defense request time) instead of in defense handler** — `chat-reaction.mjs:136-148` now calls `processD30BonusDice` in the defense request button click handler, sends the boosted `attackRoll` and full `d30AttackEffects` result in the defense request. The `createChatMessage` handler at `chat-reaction.mjs:622-626` uses the pre-computed values instead of re-processing (avoids double dice roll and ensures both clients agree on attack value). +- **Fix ensures cross-client agreement**: Player and GM clients see the same boosted attack roll because it's computed once at attack time and sent via socket, not separately per client in the defense handler. +- **BUG FIX: `d30AttackEffects` not propagated through `_storeNextDefenseData`** — added `d30AttackEffects` to `nextDefenseData` in `combat.mjs:353-355` so the `createChatMessage` handler finds pre-computed values for same-client path (was silently falling back to legacy dice-roll, causing double boost). +- **BUG FIX: `d30AttackPrecomputedStale` flag for mulligan rerolls** — added `chat-reaction.mjs:464` flag and check at `chat-reaction.mjs:632-634`. After a mulligan reroll updates `attackD30message`, the stale pre-computed effects are bypassed and the new D30 message is processed fresh via `processD30BonusDice`. + ## Key Decisions - **Auto-roll bonus dice without dialog** — matches existing D30=27 (d6E) flow - **`buildDefenseReactionButtons` extracts only button-building** — defense while-loop structures differ between same-client and cross-client; merging loops risks behavioral divergence diff --git a/module/hooks/chat-reaction.mjs b/module/hooks/chat-reaction.mjs index c4c7f21..24117b0 100644 --- a/module/hooks/chat-reaction.mjs +++ b/module/hooks/chat-reaction.mjs @@ -66,7 +66,7 @@ Hooks.on("renderChatMessageHTML", (message, html, data) => { btn.addEventListener("mouseleave", () => canvas.tokens.releaseAll()) // Gestionnaire pour les boutons de demande de défense - btn.addEventListener("click", event => { + btn.addEventListener("click", async event => { event.preventDefault() event.stopPropagation() @@ -140,13 +140,24 @@ Hooks.on("renderChatMessageHTML", (message, html, data) => { || (attackerWeapon?.system?.weaponType === "ranged") || (rollTargetOptions?.isRangedAttack === true) + // Process D30 attack bonus at source so both clients get the same boosted value. + // The d30BonusRoll chat message is created here (by _rollD30BonusDie inside processD30BonusDice). + let d30AttackEffects = null + let boostedAttackRoll = attackRoll + if (attackD30message && attacker) { + d30AttackEffects = await LethalFantasyUtils.processD30BonusDice(attackD30message, "attack", attackNaturalRoll, attacker, true) + if (d30AttackEffects.modifier) { + boostedAttackRoll = attackRoll + d30AttackEffects.modifier + } + } + const defenseMsg = { type: "requestDefense", attackerName, attackerId, defenderName, weaponName, - attackRoll, + attackRoll: boostedAttackRoll, attackWeaponId, attackRollType, attackRollKey, @@ -157,7 +168,8 @@ Hooks.on("renderChatMessageHTML", (message, html, data) => { damageTier, combatantId, tokenId, - isRanged: isRangedAttack + isRanged: isRangedAttack, + d30AttackEffects } // Envoyer le message socket à l'utilisateur contrôlant le combatant @@ -448,6 +460,10 @@ Hooks.on("createChatMessage", async (message) => { let d30Bleed = false let d30DamageMultiplier = 1 let d30DrMultiplier = 1 + // Tracks whether the pre-computed D30 effects are stale (invalidated by a mulligan reroll + // that produced a new D30 message). When a reroll updates attackD30message, the original + // d30AttackEffects from the defense request no longer apply — the new D30 must be processed. + let d30AttackPrecomputedStale = false do { mulliganRestart = false @@ -605,26 +621,42 @@ Hooks.on("createChatMessage", async (message) => { // ── D30 bonus dice (attack) — resolved before grit/luck ──────────────── if (attackD30message && !attackD30Processed) { const preD30AttackRoll = attackRollFinal - const canDialog = isPrimaryController(attacker) - const d30Result = await LethalFantasyUtils.processD30BonusDice(attackD30message, "attack", attackNaturalRoll, attacker, canDialog) + const canShow = isPrimaryController(attacker) + + // Use pre-computed D30 effects (processed at attack time in the defense + // request handler) to avoid re-rolling dice on the defense handler. + // This ensures both clients agree on the boosted attack roll. + // When d30AttackPrecomputedStale is true (invalidated by a mulligan reroll), + // skip the pre-computed effects and process the new D30 message fresh. + const usePrecomputed = !!attackData.d30AttackEffects && !d30AttackPrecomputedStale + const d30Result = usePrecomputed + ? attackData.d30AttackEffects + : await LethalFantasyUtils.processD30BonusDice(attackD30message, "attack", attackNaturalRoll, attacker, canShow) + d30AttackPrecomputedStale = false + if (d30Result.modifier) { - attackRollFinal += d30Result.modifier - if (d30Result.modifier > 0 && canDialog) { + if (!usePrecomputed) { + // Legacy or post-reroll path: bonus was just rolled, apply now + attackRollFinal += d30Result.modifier + } + // d30BonusRoll message was created at attack time (pre-processed) + // or inside _rollD30BonusDie (legacy). Either way, show the application. + if (d30Result.modifier > 0 && canShow) { await createReactionMessage(attacker, {type:"d30Bonus", actorName:attackerName, value:d30Result.modifier, side:"attack"}) } } - if (d30Result.specialEffect === "flag" && canDialog) { + if (d30Result.specialEffect === "flag" && canShow) { await createReactionMessage(attacker, {type:"d30Flag", actorName:attackerName, specialName:d30Result.specialName||"Special Effect"}) } if (d30Result.specialEffect === "bleed") { d30Bleed = true - if (canDialog) { + if (canShow) { await createReactionMessage(attacker, {type:"d30Bleed", actorName:attackerName}) } } if (d30Result.specialEffect === "damageMultiplier") { d30DamageMultiplier = d30Result.multiplier - if (canDialog) { + if (canShow) { await createReactionMessage(attacker, {type:"d30DamageMultiplier", actorName:attackerName, value:d30Result.multiplier}) } } @@ -735,6 +767,8 @@ Hooks.on("createChatMessage", async (message) => { if (reroll.options?.D30message) { attackD30message = reroll.options.D30message attackD30Processed = false + // Invalidate pre-computed effects — the new D30 message must be processed fresh + d30AttackPrecomputedStale = true } // Restart the full comparison so both sides can react to the new roll mulliganRestart = true diff --git a/module/utils/combat.mjs b/module/utils/combat.mjs index 428e314..d56d847 100644 --- a/module/utils/combat.mjs +++ b/module/utils/combat.mjs @@ -302,6 +302,7 @@ export async function showDefenseRequest(msg) { const attackD30result = msg.attackD30result const attackD30message = msg.attackD30message const attackRerollContext = msg.attackRerollContext + const d30AttackEffects = msg.d30AttackEffects const combatantId = msg.combatantId const tokenId = msg.tokenId @@ -351,6 +352,7 @@ export async function showDefenseRequest(msg) { damageTier: msg.damageTier, defenderId: defender.id, defenderTokenId, ...(msg.attackNaturalRoll !== undefined && { attackNaturalRoll: msg.attackNaturalRoll }), + ...(d30AttackEffects !== undefined && { d30AttackEffects }), ...(opts.isRanged !== undefined && { isRanged: opts.isRanged }) } }