diff --git a/module/hooks/chat-reaction.mjs b/module/hooks/chat-reaction.mjs index 73a5ff4..8d7fc16 100644 --- a/module/hooks/chat-reaction.mjs +++ b/module/hooks/chat-reaction.mjs @@ -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 })