fix: attack/defense cross-client reaction flow

- C1: Stop D30 auto-roll on non-primary clients (caused divergence)
- C2: defenderOwner fallback to GM for monster defenders
- C3: Fix tie outcome in handleAttackBoosted (>= not >)
- C5: Convert handleAttackBoosted to while-loop (multi-reaction)
- C4/C6: shouldCreateMessage cross-client guard
- M2: Coordinate main flow defender dialog vs socket handler
- M3: Fresh grit/luck reads each socket handler iteration
- M4: Include defenseD30message in socket payload + re-process
- M5: Communicate attackerHandledBonus in socket payload
- i18n: Add missing COMBAT.* keys, fix weapon.hbs label localize
- d30_results_tables: Fix string typo
This commit is contained in:
2026-06-12 02:51:59 +02:00
parent 5839616863
commit 37badf2619
5 changed files with 206 additions and 132 deletions
+30 -12
View File
@@ -633,6 +633,10 @@ Hooks.on("createChatMessage", async (message) => {
})
}
// Detect cross-client scenario: attacker has an active non-GM owner on another client
const attackerHasNonGMOwner = attacker && game.users.some(u => u.active && !u.isGM && attacker.testUserPermission(u, "OWNER"))
const attackerIsCrossClient = 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
@@ -663,7 +667,7 @@ Hooks.on("createChatMessage", async (message) => {
attackerHandledBonus = false
// ── D30 bonus dice (defense) — resolved before grit/luck/shield ───────
if (defenseD30message && !defenseD30Processed && isPrimaryController(defender)) {
if (defenseD30message && !defenseD30Processed && isPrimaryController(defender) && !attackerIsCrossClient) {
const d30Result = await LethalFantasyUtils.processD30BonusDice(defenseD30message, "defense", null, defender, true)
if (d30Result.modifier) {
defenseRoll += d30Result.modifier
@@ -694,7 +698,9 @@ Hooks.on("createChatMessage", async (message) => {
}
// ── Defense reaction loop ──────────────────────────────────────────────
if (defender && defenseRoll < attackRollFinal && isPrimaryController(defender) && !isSpellOrMiracle) {
// 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) {
while (defenseRoll < attackRollFinal) {
const currentGrit = Number(defender.system?.grit?.current) || 0
const currentLuck = Number(defender.system?.luck?.current) || 0
@@ -1003,41 +1009,53 @@ Hooks.on("createChatMessage", async (message) => {
}
}
// If attacker boosted past defense, let the defender react
if (attackerHandledBonus && defenseRoll < attackRollFinal && defender) {
// Cross-client coordination: delegate the remaining reaction + message
// to the defender's controller via socket. Only the attacker's owning
// client sends — preventing duplicate emissions from other clients.
if (defender && isPrimaryController(attacker)) {
const defenderOwner = game.users.find(u => u.active && !u.isGM && defender.testUserPermission(u, "OWNER"))
|| game.users.find(u => u.active && u.isGM)
if (defenderOwner && defenderOwner.id !== game.user.id) {
// Cross-client: send socket to defender's client
const sData = LethalFantasyUtils.getShieldReactionData(defender)
game.socket.emit(`system.${SYSTEM.id}`, {
type: "attackBoosted",
userId: defenderOwner.id,
attackerName, attackerId, defenderName, defenderId, defenderTokenId,
attackRollFinal, defenseRoll, attackWeaponId, attackRollType, attackRollKey,
attackerHandledBonus, attackRollFinal, defenseRoll, attackWeaponId, attackRollType, attackRollKey,
shieldDamageReduction: shieldBlocked ? shieldReaction?.damageReduction ?? 0 : 0,
d30Bleed: d30Bleed ? "true" : "",
d30DamageMultiplier, d30DrMultiplier,
damageTier: damageTier || "standard",
attackD30message,
defenseD30message,
hasShield: !!sData,
shieldLabel: sData?.label || "",
shieldFormula: sData?.formula || "",
shieldDr: sData?.damageReduction || 0,
canAdHocShield: !sData,
})
return // Comparison message created by defender's client
return
}
// Same client: restart for defender loop if attacker boosted past defense
if (defenseRoll < attackRollFinal && attackerHandledBonus) {
mulliganRestart = true
}
// Single-client (GM controls both): restart so defender loop can run
mulliganRestart = true
}
} while (mulliganRestart)
const shieldDamageReduction = shieldBlocked ? shieldReaction?.damageReduction ?? 0 : 0
const outcome = shieldBlocked ? "shielded-hit" : (attackRollFinal > defenseRoll ? "hit" : "miss")
// Créer le message de comparaison - uniquement par le client qui a géré le dernier bonus
// Priorité: attaquant si il a géré le bonus, sinon défenseur si il a géré le bonus, sinon défenseur
const shouldCreateMessage = attackerHandledBonus || (!attackerHandledBonus && defenderHandledBonus) || (!attackerHandledBonus && !defenderHandledBonus && isPrimaryController(defender))
// Only one client should create the comparison 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
|| (!attackerHandledBonus && defenderHandledBonus)
|| (!attackerHandledBonus && !defenderHandledBonus && (
(isPrimaryController(defender) && !attackerIsCrossClient)
|| isPrimaryController(attacker)
))
if (shouldCreateMessage) {
console.log("Creating comparison message", { attackerHandledBonus, defenderHandledBonus, isDefenderOwner: defender.isOwner })