fix: pre-process D30 attack bonus at source for cross-client agreement
Release Creation / build (release) Successful in 48s

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.
This commit is contained in:
2026-07-03 22:39:10 +02:00
parent 1ea1a4b4b7
commit 96e5bd5b4d
3 changed files with 52 additions and 10 deletions
+44 -10
View File
@@ -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
+2
View File
@@ -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 })
}
}