fix: pre-process D30 attack bonus at source for cross-client agreement
Release Creation / build (release) Successful in 48s
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user