fix: cross-client sync of D30 attack bonus so defense dialog shows

When monster (GM) rolls D30 natural 30 and applies bonus dice
(eg +D20), the boosted attackRollFinal was only applied on GM's
client. Defender (player) saw stale unboosted values — defense
dialog never appeared.

Add d30RequiresSocket flag (GM side): always send attackBoosted
socket when GM-owned attacker has D30 data, because the D30
choice/dice result is only known on GM's client.

Add d30PendingFromGM flag (player side): suppress hook-based
defense dialog and comparison message creation when D30 data
needs GM-side processing. Socket handler (handleAttackBoosted)
shows dialog with correct values.

Track d30ChangedAttack for same-client restart logic.
This commit is contained in:
2026-06-29 07:55:28 +02:00
parent 3df46b5848
commit 41b1199704
+25 -6
View File
@@ -414,6 +414,15 @@ Hooks.on("createChatMessage", async (message) => {
const attackerHasNonGMOwner = attacker && game.users.some(u => u.active && !u.isGM && attacker.testUserPermission(u, "OWNER"))
const attackerIsCrossClient = attackerHasNonGMOwner && !isPrimaryController(attacker)
// When a GM-owned attacker has D30 data, the D30 bonus is only applied on the GM's
// client. The defender must wait for the 'attackBoosted' socket to get the updated
// attack roll instead of processing the original unboosted value via the hook.
const d30PendingFromGM = attackD30message && attacker && !attackerHasNonGMOwner && !isPrimaryController(attacker)
// Mirror side: on the GM's client, this socket MUST always be sent when a GM-owned
// attacker has D30 data, even if the bonus was 0, because the defender can't know
// the outcome without it.
const d30RequiresSocket = attackD30message && attacker && !attackerHasNonGMOwner && isPrimaryController(attacker)
// Reaction phase — both sides may use grit/luck/shield/mulligan before the outcome is resolved.
// After a mulligan reroll (either side), the comparison restarts so both sides can react to the new numbers.
let defenderHandledBonus = false
@@ -433,6 +442,8 @@ Hooks.on("createChatMessage", async (message) => {
// These persist across mulligan restarts (D30 bonus only applied once)
let defenseD30Processed = false
let attackD30Processed = false
// D30 changed the attack in a way that needs cross-client sync
let d30ChangedAttack = false
// D30 combat effects for damage application
let d30Bleed = false
let d30DamageMultiplier = 1
@@ -467,9 +478,10 @@ Hooks.on("createChatMessage", async (message) => {
}
// ── Defense reaction loop ──────────────────────────────────────────────
// Skip when attacker is cross-client — the socket handler (handleAttackBoosted)
// will show the defense dialog and create the comparison message.
if (defender && defenseRoll < attackRollFinal && isPrimaryController(defender) && !isSpellOrMiracle && !attackerIsCrossClient) {
// Skip when attacker is cross-client, or when D30 bonus is pending from GM —
// the socket handler (handleAttackBoosted) will show the defense dialog and
// create the comparison message with the updated attack roll.
if (defender && defenseRoll < attackRollFinal && isPrimaryController(defender) && !isSpellOrMiracle && !attackerIsCrossClient && !d30PendingFromGM) {
while (defenseRoll < attackRollFinal) {
const currentGrit = Number(defender.system?.grit?.current) || 0
const currentLuck = Number(defender.system?.luck?.current) || 0
@@ -685,6 +697,8 @@ Hooks.on("createChatMessage", async (message) => {
}
}
attackD30Processed = true
// Track whether D30 actually changed the attack value (for cross-client sync)
d30ChangedAttack = (attackRollFinal !== preD30AttackRoll)
// If D30 boosted attack past defense, restart so defender can react.
// Only restart when D30 actually changed the outcome (pre-D30 defender was
// winning or tied, post-D30 defender is losing).
@@ -812,7 +826,7 @@ Hooks.on("createChatMessage", async (message) => {
// non-GM owner (PC-vs-PC cross-client) — the defender's hook-based
// processing is suppressed by attackerIsCrossClient, so the socket
// handler must show the defense dialog instead.
if (attackerHandledBonus || attackerHasNonGMOwner) {
if (attackerHandledBonus || attackerHasNonGMOwner || d30RequiresSocket) {
const sData = LethalFantasyUtils.getShieldReactionData(defender)
game.socket.emit(`system.${SYSTEM.id}`, {
type: "attackBoosted",
@@ -835,7 +849,8 @@ Hooks.on("createChatMessage", async (message) => {
return
}
// Same client: restart for defender loop if attacker boosted past defense
if (defenseRoll < attackRollFinal && attackerHandledBonus) {
// (either via Grit/bonus die in the attack reaction loop, or via D30 bonus)
if (defenseRoll < attackRollFinal && (attackerHandledBonus || d30ChangedAttack)) {
mulliganRestart = true
}
}
@@ -848,12 +863,16 @@ Hooks.on("createChatMessage", async (message) => {
// 1. Attacker boosted → attacker's client creates (or socket handler for cross-client)
// 2. Defender boosted → defender's client creates
// 3. Nobody boosted → attacker's client (preferred) or defender's client (fallback if same-client)
const shouldCreateMessage = attackerHandledBonus
// 4. When D30 is pending from GM, the player's client must wait for the socket
// handler — the hook-based values are stale (D30 bonus not applied).
const shouldCreateMessage = !d30PendingFromGM && (
attackerHandledBonus
|| (!attackerHandledBonus && defenderHandledBonus)
|| (!attackerHandledBonus && !defenderHandledBonus && (
(isPrimaryController(defender) && !attackerIsCrossClient)
|| isPrimaryController(attacker)
))
)
if (shouldCreateMessage) {
log("Creating comparison message", { attackerHandledBonus, defenderHandledBonus, isDefenderOwner: defender.isOwner })