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:
@@ -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 attackerHasNonGMOwner = attacker && game.users.some(u => u.active && !u.isGM && attacker.testUserPermission(u, "OWNER"))
|
||||||
const attackerIsCrossClient = attackerHasNonGMOwner && !isPrimaryController(attacker)
|
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.
|
// 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.
|
// After a mulligan reroll (either side), the comparison restarts so both sides can react to the new numbers.
|
||||||
let defenderHandledBonus = false
|
let defenderHandledBonus = false
|
||||||
@@ -433,6 +442,8 @@ Hooks.on("createChatMessage", async (message) => {
|
|||||||
// These persist across mulligan restarts (D30 bonus only applied once)
|
// These persist across mulligan restarts (D30 bonus only applied once)
|
||||||
let defenseD30Processed = false
|
let defenseD30Processed = false
|
||||||
let attackD30Processed = 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
|
// D30 combat effects for damage application
|
||||||
let d30Bleed = false
|
let d30Bleed = false
|
||||||
let d30DamageMultiplier = 1
|
let d30DamageMultiplier = 1
|
||||||
@@ -467,9 +478,10 @@ Hooks.on("createChatMessage", async (message) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Defense reaction loop ──────────────────────────────────────────────
|
// ── Defense reaction loop ──────────────────────────────────────────────
|
||||||
// Skip when attacker is cross-client — the socket handler (handleAttackBoosted)
|
// Skip when attacker is cross-client, or when D30 bonus is pending from GM —
|
||||||
// will show the defense dialog and create the comparison message.
|
// the socket handler (handleAttackBoosted) will show the defense dialog and
|
||||||
if (defender && defenseRoll < attackRollFinal && isPrimaryController(defender) && !isSpellOrMiracle && !attackerIsCrossClient) {
|
// create the comparison message with the updated attack roll.
|
||||||
|
if (defender && defenseRoll < attackRollFinal && isPrimaryController(defender) && !isSpellOrMiracle && !attackerIsCrossClient && !d30PendingFromGM) {
|
||||||
while (defenseRoll < attackRollFinal) {
|
while (defenseRoll < attackRollFinal) {
|
||||||
const currentGrit = Number(defender.system?.grit?.current) || 0
|
const currentGrit = Number(defender.system?.grit?.current) || 0
|
||||||
const currentLuck = Number(defender.system?.luck?.current) || 0
|
const currentLuck = Number(defender.system?.luck?.current) || 0
|
||||||
@@ -685,6 +697,8 @@ Hooks.on("createChatMessage", async (message) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
attackD30Processed = true
|
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.
|
// If D30 boosted attack past defense, restart so defender can react.
|
||||||
// Only restart when D30 actually changed the outcome (pre-D30 defender was
|
// Only restart when D30 actually changed the outcome (pre-D30 defender was
|
||||||
// winning or tied, post-D30 defender is losing).
|
// 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
|
// non-GM owner (PC-vs-PC cross-client) — the defender's hook-based
|
||||||
// processing is suppressed by attackerIsCrossClient, so the socket
|
// processing is suppressed by attackerIsCrossClient, so the socket
|
||||||
// handler must show the defense dialog instead.
|
// handler must show the defense dialog instead.
|
||||||
if (attackerHandledBonus || attackerHasNonGMOwner) {
|
if (attackerHandledBonus || attackerHasNonGMOwner || d30RequiresSocket) {
|
||||||
const sData = LethalFantasyUtils.getShieldReactionData(defender)
|
const sData = LethalFantasyUtils.getShieldReactionData(defender)
|
||||||
game.socket.emit(`system.${SYSTEM.id}`, {
|
game.socket.emit(`system.${SYSTEM.id}`, {
|
||||||
type: "attackBoosted",
|
type: "attackBoosted",
|
||||||
@@ -835,7 +849,8 @@ Hooks.on("createChatMessage", async (message) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Same client: restart for defender loop if attacker boosted past defense
|
// 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
|
mulliganRestart = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -848,12 +863,16 @@ Hooks.on("createChatMessage", async (message) => {
|
|||||||
// 1. Attacker boosted → attacker's client creates (or socket handler for cross-client)
|
// 1. Attacker boosted → attacker's client creates (or socket handler for cross-client)
|
||||||
// 2. Defender boosted → defender's client creates
|
// 2. Defender boosted → defender's client creates
|
||||||
// 3. Nobody boosted → attacker's client (preferred) or defender's client (fallback if same-client)
|
// 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)
|
||||||
|| (!attackerHandledBonus && !defenderHandledBonus && (
|
|| (!attackerHandledBonus && !defenderHandledBonus && (
|
||||||
(isPrimaryController(defender) && !attackerIsCrossClient)
|
(isPrimaryController(defender) && !attackerIsCrossClient)
|
||||||
|| isPrimaryController(attacker)
|
|| isPrimaryController(attacker)
|
||||||
))
|
))
|
||||||
|
)
|
||||||
|
|
||||||
if (shouldCreateMessage) {
|
if (shouldCreateMessage) {
|
||||||
log("Creating comparison message", { attackerHandledBonus, defenderHandledBonus, isDefenderOwner: defender.isOwner })
|
log("Creating comparison message", { attackerHandledBonus, defenderHandledBonus, isDefenderOwner: defender.isOwner })
|
||||||
|
|||||||
Reference in New Issue
Block a user