diff --git a/lang/en.json b/lang/en.json index 13899ae..cf9bdb9 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1,8 +1,32 @@ { "COMBAT": { + "Begin": "Begin Combat", + "Create": "Create Encounter", + "Delete": "Delete Encounter", + "Encounter": "Encounter", + "EncounterNext": "Next Encounter", + "EncounterPrevious": "Previous Encounter", + "End": "End Combat", + "InitiativeReset": "Reset Initiative", + "InitiativeRoll": "Roll Initiative", + "InitiativeScore": "Initiative Score", + "NavLabel": "Combat Tracker Navigation", + "None": "None", + "NotStarted": "Not Started", + "PanToCombatant": "Pan to Combatant", + "PingCombatant": "Ping Combatant", + "RollAll": "Roll All", + "RollNPC": "Roll NPCs", "Round": "Second {round}", + "RoundNext": "Next second", + "RoundPrev": "Previous second", "Rounds": "Seconds", - "RoundNext": "Next second" + "Settings": "Combat Settings", + "ToggleDead": "Toggle Dead", + "ToggleVis": "Toggle Visible", + "TurnEnd": "End Turn", + "TurnNext": "Next Turn", + "TurnPrev": "Previous Turn" }, "LETHALFANTASY": { "Armor": { diff --git a/lethal-fantasy.mjs b/lethal-fantasy.mjs index 3b9e1c8..43a907f 100644 --- a/lethal-fantasy.mjs +++ b/lethal-fantasy.mjs @@ -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 }) diff --git a/module/config/d30_results_tables.json b/module/config/d30_results_tables.json index 8d04f70..77d8752 100644 --- a/module/config/d30_results_tables.json +++ b/module/config/d30_results_tables.json @@ -429,7 +429,7 @@ "melee_attack": { "type": "flurry", "condition": "hit_or_miss", - "description": "Flurry Attack or Hit to Miss" + "description": "Flurry Attack on Hit or Miss" }, "ranged_attack": { "type": "double_damage_dice", diff --git a/module/utils.mjs b/module/utils.mjs index 6baae2f..e05e9f6 100644 --- a/module/utils.mjs +++ b/module/utils.mjs @@ -235,10 +235,10 @@ export default class LethalFantasyUtils { static async handleAttackBoosted(msg) { const { attackerName, attackerId, defenderName, defenderId, defenderTokenId, - attackRollFinal, defenseRoll, attackWeaponId, attackRollType, attackRollKey, + attackerHandledBonus, attackRollFinal, defenseRoll, attackWeaponId, attackRollType, attackRollKey, shieldDamageReduction: initialShieldDR, d30Bleed, d30DamageMultiplier, d30DrMultiplier, - damageTier, attackD30message, + damageTier, attackD30message, defenseD30message, hasShield, shieldLabel, shieldFormula, shieldDr, canAdHocShield } = msg @@ -251,138 +251,177 @@ export default class LethalFantasyUtils { let canShieldReact = hasShield let canAdHoc = canAdHocShield - // Show the defense reaction dialog - if (defender && updatedDefenseRoll < attackRollFinal) { - const currentGrit = Number(defender.system?.grit?.current) || 0 - const currentLuck = Number(defender.system?.luck?.current) || 0 - const buttons = [] - - if (currentGrit > 0) { - buttons.push({ - action: "grit", - label: `Spend 1 Grit (+1D6) [${currentGrit} left]`, - icon: "fa-solid fa-fist-raised", - callback: () => "grit" + // ── D30 bonus dice (defense) — resolved before grit/luck/shield ─────── + let defenseDrMultiplier = null + if (defenseD30message && defender) { + const d30Result = await LethalFantasyUtils.processD30BonusDice(defenseD30message, "defense", null, defender, true) + if (d30Result.modifier) { + updatedDefenseRoll += d30Result.modifier + if (d30Result.modifier > 0) { + await ChatMessage.create({ + content: `
${defenderName} gains +${d30Result.modifier} from D30 bonus die for defense.
`, + speaker: ChatMessage.getSpeaker({ actor: defender }) + }) + } + } + if (d30Result.specialEffect === "auto") { + updatedDefenseRoll = attackRollFinal + 1 + await ChatMessage.create({ + content: `${defenderName} uses ${d30Result.specialName || "Special Defense"} from D30 — defense automatically succeeds!
`, + speaker: ChatMessage.getSpeaker({ actor: defender }) }) } - - if (currentLuck > 0) { - buttons.push({ - action: "luck", - label: `Spend 1 Luck (+1D6) [${currentLuck} left]`, - icon: "fa-solid fa-clover", - callback: () => "luck" + if (d30Result.specialEffect === "flag") { + await ChatMessage.create({ + content: `D30 — ${d30Result.specialName || "Special Effect"} triggered for ${defenderName}!
`, + speaker: ChatMessage.getSpeaker({ actor: defender }) }) } - - buttons.push({ - action: "bonusDie", - label: "Add bonus die", - icon: "fa-solid fa-dice", - callback: () => "bonusDie" - }) - - if (canShieldReact) { - buttons.push({ - action: "shieldReact", - label: `Roll shield (${shieldLabel})`, - icon: "fa-solid fa-shield", - callback: () => "shieldReact" - }) - } else if (canAdHoc) { - buttons.push({ - action: "adHocShield", - label: "Roll ad-hoc shield (choose dice + DR)", - icon: "fa-solid fa-shield-halved", - callback: () => "adHocShield" + if (d30Result.specialEffect === "drMultiplier") { + defenseDrMultiplier = d30Result.multiplier + await ChatMessage.create({ + content: `D30 — Defense grants x${d30Result.multiplier} DR (choose which DR types to multiply when damage is applied)
`, + speaker: ChatMessage.getSpeaker({ actor: defender }) }) } + } - buttons.push({ - action: "continue", - label: "Continue (no defense bonus)", - icon: "fa-solid fa-forward", - callback: () => "continue" - }) + // Show the defense reaction dialog — while-loop for multiple reactions + if (defender) { + while (updatedDefenseRoll < attackRollFinal) { + const currentGrit = Number(defender.system?.grit?.current) || 0 + const currentLuck = Number(defender.system?.luck?.current) || 0 + const buttons = [] - const choice = await foundry.applications.api.DialogV2.wait({ - window: { title: "Defense reactions — attack boosted" }, - classes: ["lethalfantasy"], - content: ` -${attackerName} boosted attack to ${attackRollFinal}
-${defenderName} currently has ${updatedDefenseRoll}
+ if (currentGrit > 0) { + buttons.push({ + action: "grit", + label: `Spend 1 Grit (+1D6) [${currentGrit} left]`, + icon: "fa-solid fa-fist-raised", + callback: () => "grit" + }) + } + + if (currentLuck > 0) { + buttons.push({ + action: "luck", + label: `Spend 1 Luck (+1D6) [${currentLuck} left]`, + icon: "fa-solid fa-clover", + callback: () => "luck" + }) + } + + buttons.push({ + action: "bonusDie", + label: "Add bonus die", + icon: "fa-solid fa-dice", + callback: () => "bonusDie" + }) + + if (canShieldReact) { + buttons.push({ + action: "shieldReact", + label: `Roll shield (${shieldLabel})`, + icon: "fa-solid fa-shield", + callback: () => "shieldReact" + }) + } else if (canAdHoc) { + buttons.push({ + action: "adHocShield", + label: "Roll ad-hoc shield (choose dice + DR)", + icon: "fa-solid fa-shield-halved", + callback: () => "adHocShield" + }) + } + + buttons.push({ + action: "continue", + label: "Continue (no defense bonus)", + icon: "fa-solid fa-forward", + callback: () => "continue" + }) + + const choice = await foundry.applications.api.DialogV2.wait({ + window: { title: "Defense reactions — attack boosted" }, + classes: ["lethalfantasy"], + content: ` +${attackerName} boosted attack to ${attackRollFinal}
+${defenderName} currently has ${updatedDefenseRoll}
+The attack was boosted! Choose how to improve the defense.
The attack was boosted! Choose how to improve the defense.
-${defenderName} spends 1 Grit and rolls ${total} for defense.
`) - updatedDefenseRoll += bonusRoll - await defender.update({ "system.grit.current": currentGrit - 1 }) - } else if (choice === "luck") { - const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender, - total => `${defenderName} spends 1 Luck and rolls ${total} for defense.
`) - updatedDefenseRoll += bonusRoll - await defender.update({ "system.luck.current": currentLuck - 1 }) - } else if (choice === "bonusDie") { - const bonusDie = await LethalFantasyUtils.promptCombatBonusDie(defenderName, "attack", updatedDefenseRoll, attackRollFinal) - if (bonusDie) { - const bonusRoll = await LethalFantasyUtils.rollBonusDie(bonusDie, defender, - (total, formula) => `${defenderName} adds ${formula.toUpperCase()} and rolls ${total} for defense.
`) + if (!choice || choice === "continue") break + + if (choice === "grit") { + const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender, + total => `${defenderName} spends 1 Grit and rolls ${total} for defense.
`) updatedDefenseRoll += bonusRoll - } - } else if (choice === "shieldReact" && canShieldReact) { - const shieldBonus = await LethalFantasyUtils.rollBonusDie(shieldFormula, defender) - const newDefenseTotal = updatedDefenseRoll + shieldBonus - updatedDefenseRoll = newDefenseTotal - canShieldReact = false - if (newDefenseTotal >= attackRollFinal) { - shieldBlocked = true - shieldReaction = { damageReduction: shieldDr, label: shieldLabel, bonus: shieldBonus } - await ChatMessage.create({ - content: `${defenderName} rolls ${shieldLabel} and adds ${shieldBonus} to defense (${newDefenseTotal} ≥ ${attackRollFinal}). Shield blocked the attack! Both armor DR and shield DR ${shieldDr} will apply to damage.
`, - speaker: ChatMessage.getSpeaker({ actor: defender }) - }) - } else { - await ChatMessage.create({ - content: `${defenderName} rolls ${shieldLabel} and adds ${shieldBonus} to defense (${newDefenseTotal} < ${attackRollFinal}). Shield did not block — normal hit, armor DR only.
`, - speaker: ChatMessage.getSpeaker({ actor: defender }) - }) - } - } else if (choice === "adHocShield" && canAdHoc) { - const adHoc = await LethalFantasyUtils.promptAdHocShield(defenderName, attackRollFinal, updatedDefenseRoll) - if (adHoc) { - const shieldBonus = await LethalFantasyUtils.rollBonusDie(adHoc.formula, defender) + await defender.update({ "system.grit.current": currentGrit - 1 }) + } else if (choice === "luck") { + const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender, + total => `${defenderName} spends 1 Luck and rolls ${total} for defense.
`) + updatedDefenseRoll += bonusRoll + await defender.update({ "system.luck.current": currentLuck - 1 }) + } else if (choice === "bonusDie") { + const bonusDie = await LethalFantasyUtils.promptCombatBonusDie(defenderName, "attack", updatedDefenseRoll, attackRollFinal) + if (bonusDie) { + const bonusRoll = await LethalFantasyUtils.rollBonusDie(bonusDie, defender, + (total, formula) => `${defenderName} adds ${formula.toUpperCase()} and rolls ${total} for defense.
`) + updatedDefenseRoll += bonusRoll + } + } else if (choice === "shieldReact" && canShieldReact) { + const shieldBonus = await LethalFantasyUtils.rollBonusDie(shieldFormula, defender) const newDefenseTotal = updatedDefenseRoll + shieldBonus updatedDefenseRoll = newDefenseTotal canShieldReact = false - canAdHoc = false if (newDefenseTotal >= attackRollFinal) { shieldBlocked = true - shieldReaction = { damageReduction: adHoc.damageReduction, label: `${adHoc.formula.toUpperCase()} shield`, bonus: shieldBonus } + shieldReaction = { damageReduction: shieldDr, label: shieldLabel, bonus: shieldBonus } await ChatMessage.create({ - content: `${defenderName} rolls ${adHoc.formula.toUpperCase()} shield and adds ${shieldBonus} to defense (${newDefenseTotal} ≥ ${attackRollFinal}). Shield blocked the attack! Both armor DR and shield DR ${adHoc.damageReduction} will apply to damage.
`, + content: `${defenderName} rolls ${shieldLabel} and adds ${shieldBonus} to defense (${newDefenseTotal} ≥ ${attackRollFinal}). Shield blocked the attack! Both armor DR and shield DR ${shieldDr} will apply to damage.
`, speaker: ChatMessage.getSpeaker({ actor: defender }) }) } else { await ChatMessage.create({ - content: `${defenderName} rolls ${adHoc.formula.toUpperCase()} shield and adds ${shieldBonus} to defense (${newDefenseTotal} < ${attackRollFinal}). Shield did not block — normal hit, armor DR only.
`, + content: `${defenderName} rolls ${shieldLabel} and adds ${shieldBonus} to defense (${newDefenseTotal} < ${attackRollFinal}). Shield did not block — normal hit, armor DR only.
`, speaker: ChatMessage.getSpeaker({ actor: defender }) }) } + } else if (choice === "adHocShield" && canAdHoc) { + const adHoc = await LethalFantasyUtils.promptAdHocShield(defenderName, attackRollFinal, updatedDefenseRoll) + if (adHoc) { + const shieldBonus = await LethalFantasyUtils.rollBonusDie(adHoc.formula, defender) + const newDefenseTotal = updatedDefenseRoll + shieldBonus + updatedDefenseRoll = newDefenseTotal + canShieldReact = false + canAdHoc = false + if (newDefenseTotal >= attackRollFinal) { + shieldBlocked = true + shieldReaction = { damageReduction: adHoc.damageReduction, label: `${adHoc.formula.toUpperCase()} shield`, bonus: shieldBonus } + await ChatMessage.create({ + content: `${defenderName} rolls ${adHoc.formula.toUpperCase()} shield and adds ${shieldBonus} to defense (${newDefenseTotal} ≥ ${attackRollFinal}). Shield blocked the attack! Both armor DR and shield DR ${adHoc.damageReduction} will apply to damage.
`, + speaker: ChatMessage.getSpeaker({ actor: defender }) + }) + } else { + await ChatMessage.create({ + content: `${defenderName} rolls ${adHoc.formula.toUpperCase()} shield and adds ${shieldBonus} to defense (${newDefenseTotal} < ${attackRollFinal}). Shield did not block — normal hit, armor DR only.
`, + speaker: ChatMessage.getSpeaker({ actor: defender }) + }) + } + } } } } const finalShieldDR = shieldBlocked ? (shieldReaction?.damageReduction || 0) : 0 - const outcome = shieldBlocked ? "shielded-hit" : (updatedDefenseRoll > attackRollFinal ? "miss" : "hit") + const outcome = shieldBlocked ? "shielded-hit" : (updatedDefenseRoll >= attackRollFinal ? "miss" : "hit") await LethalFantasyUtils.compareAttackDefense({ attackerName, @@ -399,7 +438,7 @@ export default class LethalFantasyUtils { shieldDamageReduction: finalShieldDR, d30Bleed: d30Bleed || "", d30DamageMultiplier: d30DamageMultiplier || 1, - d30DrMultiplier: d30DrMultiplier || 1, + d30DrMultiplier: (d30DrMultiplier != null && d30DrMultiplier !== 1) ? d30DrMultiplier : (defenseDrMultiplier ?? d30DrMultiplier ?? 1), damageTier: damageTier || "standard", attackD30message }) @@ -742,18 +781,11 @@ export default class LethalFantasyUtils { // ── Choice type ── present all options to the player if (d30Message.type === "choice") { - // Try to find a bonus_dice option matching this side - const autoBonus = d30Message.choices.find(c => c.type === "bonus_dice" && validTargets.includes(c.target)) - - // If we can't show dialogs (wrong client), auto-roll bonus dice if available + // If we can't show dialogs (wrong client), skip — the primary client + // will communicate its choice result via socket. Auto-rolling here + // would give a different modifier on each client, causing divergence. if (!canDialog) { - if (autoBonus) { - const modifier = await this._rollD30BonusDie(autoBonus.dice, actor, true) - return { modifier, specialEffect: null, specialName: null } - } - // No bonus dice available on this side — just report as flag - const first = d30Message.choices[0] - return { modifier: 0, specialEffect: "flag", specialName: first?.type || "choice" } + return { modifier: 0, specialEffect: null, specialName: null } } const buttons = d30Message.choices.map(c => { diff --git a/templates/weapon.hbs b/templates/weapon.hbs index 57e48dd..7f2375e 100644 --- a/templates/weapon.hbs +++ b/templates/weapon.hbs @@ -17,7 +17,7 @@ {{formField systemFields.damageType.fields.typeS value=system.damageType.typeS}}