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:
+25
-1
@@ -1,8 +1,32 @@
|
|||||||
{
|
{
|
||||||
"COMBAT": {
|
"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}",
|
"Round": "Second {round}",
|
||||||
|
"RoundNext": "Next second",
|
||||||
|
"RoundPrev": "Previous second",
|
||||||
"Rounds": "Seconds",
|
"Rounds": "Seconds",
|
||||||
"RoundNext": "Next second"
|
"Settings": "Combat Settings",
|
||||||
|
"ToggleDead": "Toggle Dead",
|
||||||
|
"ToggleVis": "Toggle Visible",
|
||||||
|
"TurnEnd": "End Turn",
|
||||||
|
"TurnNext": "Next Turn",
|
||||||
|
"TurnPrev": "Previous Turn"
|
||||||
},
|
},
|
||||||
"LETHALFANTASY": {
|
"LETHALFANTASY": {
|
||||||
"Armor": {
|
"Armor": {
|
||||||
|
|||||||
+30
-12
@@ -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.
|
// 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
|
||||||
@@ -663,7 +667,7 @@ Hooks.on("createChatMessage", async (message) => {
|
|||||||
attackerHandledBonus = false
|
attackerHandledBonus = false
|
||||||
|
|
||||||
// ── D30 bonus dice (defense) — resolved before grit/luck/shield ───────
|
// ── 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)
|
const d30Result = await LethalFantasyUtils.processD30BonusDice(defenseD30message, "defense", null, defender, true)
|
||||||
if (d30Result.modifier) {
|
if (d30Result.modifier) {
|
||||||
defenseRoll += d30Result.modifier
|
defenseRoll += d30Result.modifier
|
||||||
@@ -694,7 +698,9 @@ Hooks.on("createChatMessage", async (message) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Defense reaction loop ──────────────────────────────────────────────
|
// ── 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) {
|
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
|
||||||
@@ -1003,41 +1009,53 @@ Hooks.on("createChatMessage", async (message) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If attacker boosted past defense, let the defender react
|
// Cross-client coordination: delegate the remaining reaction + message
|
||||||
if (attackerHandledBonus && defenseRoll < attackRollFinal && defender) {
|
// 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"))
|
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) {
|
if (defenderOwner && defenderOwner.id !== game.user.id) {
|
||||||
// Cross-client: send socket to defender's client
|
|
||||||
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",
|
||||||
userId: defenderOwner.id,
|
userId: defenderOwner.id,
|
||||||
attackerName, attackerId, defenderName, defenderId, defenderTokenId,
|
attackerName, attackerId, defenderName, defenderId, defenderTokenId,
|
||||||
attackRollFinal, defenseRoll, attackWeaponId, attackRollType, attackRollKey,
|
attackerHandledBonus, attackRollFinal, defenseRoll, attackWeaponId, attackRollType, attackRollKey,
|
||||||
shieldDamageReduction: shieldBlocked ? shieldReaction?.damageReduction ?? 0 : 0,
|
shieldDamageReduction: shieldBlocked ? shieldReaction?.damageReduction ?? 0 : 0,
|
||||||
d30Bleed: d30Bleed ? "true" : "",
|
d30Bleed: d30Bleed ? "true" : "",
|
||||||
d30DamageMultiplier, d30DrMultiplier,
|
d30DamageMultiplier, d30DrMultiplier,
|
||||||
damageTier: damageTier || "standard",
|
damageTier: damageTier || "standard",
|
||||||
attackD30message,
|
attackD30message,
|
||||||
|
defenseD30message,
|
||||||
hasShield: !!sData,
|
hasShield: !!sData,
|
||||||
shieldLabel: sData?.label || "",
|
shieldLabel: sData?.label || "",
|
||||||
shieldFormula: sData?.formula || "",
|
shieldFormula: sData?.formula || "",
|
||||||
shieldDr: sData?.damageReduction || 0,
|
shieldDr: sData?.damageReduction || 0,
|
||||||
canAdHocShield: !sData,
|
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)
|
} while (mulliganRestart)
|
||||||
|
|
||||||
const shieldDamageReduction = shieldBlocked ? shieldReaction?.damageReduction ?? 0 : 0
|
const shieldDamageReduction = shieldBlocked ? shieldReaction?.damageReduction ?? 0 : 0
|
||||||
const outcome = shieldBlocked ? "shielded-hit" : (attackRollFinal > defenseRoll ? "hit" : "miss")
|
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
|
// Only one client should create the comparison message:
|
||||||
// Priorité: attaquant si il a géré le bonus, sinon défenseur si il a géré le bonus, sinon défenseur
|
// 1. Attacker boosted → attacker's client creates (or socket handler for cross-client)
|
||||||
const shouldCreateMessage = attackerHandledBonus || (!attackerHandledBonus && defenderHandledBonus) || (!attackerHandledBonus && !defenderHandledBonus && isPrimaryController(defender))
|
// 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) {
|
if (shouldCreateMessage) {
|
||||||
console.log("Creating comparison message", { attackerHandledBonus, defenderHandledBonus, isDefenderOwner: defender.isOwner })
|
console.log("Creating comparison message", { attackerHandledBonus, defenderHandledBonus, isDefenderOwner: defender.isOwner })
|
||||||
|
|||||||
@@ -429,7 +429,7 @@
|
|||||||
"melee_attack": {
|
"melee_attack": {
|
||||||
"type": "flurry",
|
"type": "flurry",
|
||||||
"condition": "hit_or_miss",
|
"condition": "hit_or_miss",
|
||||||
"description": "Flurry Attack or Hit to Miss"
|
"description": "Flurry Attack on Hit or Miss"
|
||||||
},
|
},
|
||||||
"ranged_attack": {
|
"ranged_attack": {
|
||||||
"type": "double_damage_dice",
|
"type": "double_damage_dice",
|
||||||
|
|||||||
+149
-117
@@ -235,10 +235,10 @@ export default class LethalFantasyUtils {
|
|||||||
static async handleAttackBoosted(msg) {
|
static async handleAttackBoosted(msg) {
|
||||||
const {
|
const {
|
||||||
attackerName, attackerId, defenderName, defenderId, defenderTokenId,
|
attackerName, attackerId, defenderName, defenderId, defenderTokenId,
|
||||||
attackRollFinal, defenseRoll, attackWeaponId, attackRollType, attackRollKey,
|
attackerHandledBonus, attackRollFinal, defenseRoll, attackWeaponId, attackRollType, attackRollKey,
|
||||||
shieldDamageReduction: initialShieldDR,
|
shieldDamageReduction: initialShieldDR,
|
||||||
d30Bleed, d30DamageMultiplier, d30DrMultiplier,
|
d30Bleed, d30DamageMultiplier, d30DrMultiplier,
|
||||||
damageTier, attackD30message,
|
damageTier, attackD30message, defenseD30message,
|
||||||
hasShield, shieldLabel, shieldFormula, shieldDr, canAdHocShield
|
hasShield, shieldLabel, shieldFormula, shieldDr, canAdHocShield
|
||||||
} = msg
|
} = msg
|
||||||
|
|
||||||
@@ -251,138 +251,177 @@ export default class LethalFantasyUtils {
|
|||||||
let canShieldReact = hasShield
|
let canShieldReact = hasShield
|
||||||
let canAdHoc = canAdHocShield
|
let canAdHoc = canAdHocShield
|
||||||
|
|
||||||
// Show the defense reaction dialog
|
// ── D30 bonus dice (defense) — resolved before grit/luck/shield ───────
|
||||||
if (defender && updatedDefenseRoll < attackRollFinal) {
|
let defenseDrMultiplier = null
|
||||||
const currentGrit = Number(defender.system?.grit?.current) || 0
|
if (defenseD30message && defender) {
|
||||||
const currentLuck = Number(defender.system?.luck?.current) || 0
|
const d30Result = await LethalFantasyUtils.processD30BonusDice(defenseD30message, "defense", null, defender, true)
|
||||||
const buttons = []
|
if (d30Result.modifier) {
|
||||||
|
updatedDefenseRoll += d30Result.modifier
|
||||||
if (currentGrit > 0) {
|
if (d30Result.modifier > 0) {
|
||||||
buttons.push({
|
await ChatMessage.create({
|
||||||
action: "grit",
|
content: `<p><strong>${defenderName}</strong> gains <strong>+${d30Result.modifier}</strong> from D30 bonus die for defense.</p>`,
|
||||||
label: `Spend 1 Grit (+1D6) [${currentGrit} left]`,
|
speaker: ChatMessage.getSpeaker({ actor: defender })
|
||||||
icon: "fa-solid fa-fist-raised",
|
})
|
||||||
callback: () => "grit"
|
}
|
||||||
|
}
|
||||||
|
if (d30Result.specialEffect === "auto") {
|
||||||
|
updatedDefenseRoll = attackRollFinal + 1
|
||||||
|
await ChatMessage.create({
|
||||||
|
content: `<p><strong>${defenderName}</strong> uses <strong>${d30Result.specialName || "Special Defense"}</strong> from D30 — defense automatically succeeds!</p>`,
|
||||||
|
speaker: ChatMessage.getSpeaker({ actor: defender })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (d30Result.specialEffect === "flag") {
|
||||||
if (currentLuck > 0) {
|
await ChatMessage.create({
|
||||||
buttons.push({
|
content: `<p>D30 — <strong>${d30Result.specialName || "Special Effect"}</strong> triggered for ${defenderName}!</p>`,
|
||||||
action: "luck",
|
speaker: ChatMessage.getSpeaker({ actor: defender })
|
||||||
label: `Spend 1 Luck (+1D6) [${currentLuck} left]`,
|
|
||||||
icon: "fa-solid fa-clover",
|
|
||||||
callback: () => "luck"
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (d30Result.specialEffect === "drMultiplier") {
|
||||||
buttons.push({
|
defenseDrMultiplier = d30Result.multiplier
|
||||||
action: "bonusDie",
|
await ChatMessage.create({
|
||||||
label: "Add bonus die",
|
content: `<p>D30 — Defense grants <strong>x${d30Result.multiplier} DR</strong> (choose which DR types to multiply when damage is applied)</p>`,
|
||||||
icon: "fa-solid fa-dice",
|
speaker: ChatMessage.getSpeaker({ actor: defender })
|
||||||
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({
|
// Show the defense reaction dialog — while-loop for multiple reactions
|
||||||
action: "continue",
|
if (defender) {
|
||||||
label: "Continue (no defense bonus)",
|
while (updatedDefenseRoll < attackRollFinal) {
|
||||||
icon: "fa-solid fa-forward",
|
const currentGrit = Number(defender.system?.grit?.current) || 0
|
||||||
callback: () => "continue"
|
const currentLuck = Number(defender.system?.luck?.current) || 0
|
||||||
})
|
const buttons = []
|
||||||
|
|
||||||
const choice = await foundry.applications.api.DialogV2.wait({
|
if (currentGrit > 0) {
|
||||||
window: { title: "Defense reactions — attack boosted" },
|
buttons.push({
|
||||||
classes: ["lethalfantasy"],
|
action: "grit",
|
||||||
content: `
|
label: `Spend 1 Grit (+1D6) [${currentGrit} left]`,
|
||||||
<div class="grit-luck-dialog">
|
icon: "fa-solid fa-fist-raised",
|
||||||
<div class="combat-status">
|
callback: () => "grit"
|
||||||
<p><strong>${attackerName}</strong> boosted attack to <strong>${attackRollFinal}</strong></p>
|
})
|
||||||
<p><strong>${defenderName}</strong> currently has <strong>${updatedDefenseRoll}</strong></p>
|
}
|
||||||
|
|
||||||
|
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: `
|
||||||
|
<div class="grit-luck-dialog">
|
||||||
|
<div class="combat-status">
|
||||||
|
<p><strong>${attackerName}</strong> boosted attack to <strong>${attackRollFinal}</strong></p>
|
||||||
|
<p><strong>${defenderName}</strong> currently has <strong>${updatedDefenseRoll}</strong></p>
|
||||||
|
</div>
|
||||||
|
<p class="offer-text">The attack was boosted! Choose how to improve the defense.</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="offer-text">The attack was boosted! Choose how to improve the defense.</p>
|
`,
|
||||||
</div>
|
buttons,
|
||||||
`,
|
rejectClose: false
|
||||||
buttons,
|
})
|
||||||
rejectClose: false
|
|
||||||
})
|
|
||||||
|
|
||||||
if (choice === "grit") {
|
if (!choice || choice === "continue") break
|
||||||
const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender,
|
|
||||||
total => `<p><strong>${defenderName}</strong> spends 1 Grit and rolls <strong>${total}</strong> for defense.</p>`)
|
if (choice === "grit") {
|
||||||
updatedDefenseRoll += bonusRoll
|
const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender,
|
||||||
await defender.update({ "system.grit.current": currentGrit - 1 })
|
total => `<p><strong>${defenderName}</strong> spends 1 Grit and rolls <strong>${total}</strong> for defense.</p>`)
|
||||||
} else if (choice === "luck") {
|
|
||||||
const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender,
|
|
||||||
total => `<p><strong>${defenderName}</strong> spends 1 Luck and rolls <strong>${total}</strong> for defense.</p>`)
|
|
||||||
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) => `<p><strong>${defenderName}</strong> adds <strong>${formula.toUpperCase()}</strong> and rolls <strong>${total}</strong> for defense.</p>`)
|
|
||||||
updatedDefenseRoll += bonusRoll
|
updatedDefenseRoll += bonusRoll
|
||||||
}
|
await defender.update({ "system.grit.current": currentGrit - 1 })
|
||||||
} else if (choice === "shieldReact" && canShieldReact) {
|
} else if (choice === "luck") {
|
||||||
const shieldBonus = await LethalFantasyUtils.rollBonusDie(shieldFormula, defender)
|
const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender,
|
||||||
const newDefenseTotal = updatedDefenseRoll + shieldBonus
|
total => `<p><strong>${defenderName}</strong> spends 1 Luck and rolls <strong>${total}</strong> for defense.</p>`)
|
||||||
updatedDefenseRoll = newDefenseTotal
|
updatedDefenseRoll += bonusRoll
|
||||||
canShieldReact = false
|
await defender.update({ "system.luck.current": currentLuck - 1 })
|
||||||
if (newDefenseTotal >= attackRollFinal) {
|
} else if (choice === "bonusDie") {
|
||||||
shieldBlocked = true
|
const bonusDie = await LethalFantasyUtils.promptCombatBonusDie(defenderName, "attack", updatedDefenseRoll, attackRollFinal)
|
||||||
shieldReaction = { damageReduction: shieldDr, label: shieldLabel, bonus: shieldBonus }
|
if (bonusDie) {
|
||||||
await ChatMessage.create({
|
const bonusRoll = await LethalFantasyUtils.rollBonusDie(bonusDie, defender,
|
||||||
content: `<p><strong>${defenderName}</strong> rolls <strong>${shieldLabel}</strong> and adds <strong>${shieldBonus}</strong> to defense (${newDefenseTotal} ≥ ${attackRollFinal}). <strong>Shield blocked the attack!</strong> Both armor DR and shield DR <strong>${shieldDr}</strong> will apply to damage.</p>`,
|
(total, formula) => `<p><strong>${defenderName}</strong> adds <strong>${formula.toUpperCase()}</strong> and rolls <strong>${total}</strong> for defense.</p>`)
|
||||||
speaker: ChatMessage.getSpeaker({ actor: defender })
|
updatedDefenseRoll += bonusRoll
|
||||||
})
|
}
|
||||||
} else {
|
} else if (choice === "shieldReact" && canShieldReact) {
|
||||||
await ChatMessage.create({
|
const shieldBonus = await LethalFantasyUtils.rollBonusDie(shieldFormula, defender)
|
||||||
content: `<p><strong>${defenderName}</strong> rolls <strong>${shieldLabel}</strong> and adds <strong>${shieldBonus}</strong> to defense (${newDefenseTotal} < ${attackRollFinal}). Shield did not block — normal hit, armor DR only.</p>`,
|
|
||||||
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
|
const newDefenseTotal = updatedDefenseRoll + shieldBonus
|
||||||
updatedDefenseRoll = newDefenseTotal
|
updatedDefenseRoll = newDefenseTotal
|
||||||
canShieldReact = false
|
canShieldReact = false
|
||||||
canAdHoc = false
|
|
||||||
if (newDefenseTotal >= attackRollFinal) {
|
if (newDefenseTotal >= attackRollFinal) {
|
||||||
shieldBlocked = true
|
shieldBlocked = true
|
||||||
shieldReaction = { damageReduction: adHoc.damageReduction, label: `${adHoc.formula.toUpperCase()} shield`, bonus: shieldBonus }
|
shieldReaction = { damageReduction: shieldDr, label: shieldLabel, bonus: shieldBonus }
|
||||||
await ChatMessage.create({
|
await ChatMessage.create({
|
||||||
content: `<p><strong>${defenderName}</strong> rolls <strong>${adHoc.formula.toUpperCase()}</strong> shield and adds <strong>${shieldBonus}</strong> to defense (${newDefenseTotal} ≥ ${attackRollFinal}). <strong>Shield blocked the attack!</strong> Both armor DR and shield DR <strong>${adHoc.damageReduction}</strong> will apply to damage.</p>`,
|
content: `<p><strong>${defenderName}</strong> rolls <strong>${shieldLabel}</strong> and adds <strong>${shieldBonus}</strong> to defense (${newDefenseTotal} ≥ ${attackRollFinal}). <strong>Shield blocked the attack!</strong> Both armor DR and shield DR <strong>${shieldDr}</strong> will apply to damage.</p>`,
|
||||||
speaker: ChatMessage.getSpeaker({ actor: defender })
|
speaker: ChatMessage.getSpeaker({ actor: defender })
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
await ChatMessage.create({
|
await ChatMessage.create({
|
||||||
content: `<p><strong>${defenderName}</strong> rolls <strong>${adHoc.formula.toUpperCase()}</strong> shield and adds <strong>${shieldBonus}</strong> to defense (${newDefenseTotal} < ${attackRollFinal}). Shield did not block — normal hit, armor DR only.</p>`,
|
content: `<p><strong>${defenderName}</strong> rolls <strong>${shieldLabel}</strong> and adds <strong>${shieldBonus}</strong> to defense (${newDefenseTotal} < ${attackRollFinal}). Shield did not block — normal hit, armor DR only.</p>`,
|
||||||
speaker: ChatMessage.getSpeaker({ actor: defender })
|
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: `<p><strong>${defenderName}</strong> rolls <strong>${adHoc.formula.toUpperCase()}</strong> shield and adds <strong>${shieldBonus}</strong> to defense (${newDefenseTotal} ≥ ${attackRollFinal}). <strong>Shield blocked the attack!</strong> Both armor DR and shield DR <strong>${adHoc.damageReduction}</strong> will apply to damage.</p>`,
|
||||||
|
speaker: ChatMessage.getSpeaker({ actor: defender })
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await ChatMessage.create({
|
||||||
|
content: `<p><strong>${defenderName}</strong> rolls <strong>${adHoc.formula.toUpperCase()}</strong> shield and adds <strong>${shieldBonus}</strong> to defense (${newDefenseTotal} < ${attackRollFinal}). Shield did not block — normal hit, armor DR only.</p>`,
|
||||||
|
speaker: ChatMessage.getSpeaker({ actor: defender })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalShieldDR = shieldBlocked ? (shieldReaction?.damageReduction || 0) : 0
|
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({
|
await LethalFantasyUtils.compareAttackDefense({
|
||||||
attackerName,
|
attackerName,
|
||||||
@@ -399,7 +438,7 @@ export default class LethalFantasyUtils {
|
|||||||
shieldDamageReduction: finalShieldDR,
|
shieldDamageReduction: finalShieldDR,
|
||||||
d30Bleed: d30Bleed || "",
|
d30Bleed: d30Bleed || "",
|
||||||
d30DamageMultiplier: d30DamageMultiplier || 1,
|
d30DamageMultiplier: d30DamageMultiplier || 1,
|
||||||
d30DrMultiplier: d30DrMultiplier || 1,
|
d30DrMultiplier: (d30DrMultiplier != null && d30DrMultiplier !== 1) ? d30DrMultiplier : (defenseDrMultiplier ?? d30DrMultiplier ?? 1),
|
||||||
damageTier: damageTier || "standard",
|
damageTier: damageTier || "standard",
|
||||||
attackD30message
|
attackD30message
|
||||||
})
|
})
|
||||||
@@ -742,18 +781,11 @@ export default class LethalFantasyUtils {
|
|||||||
|
|
||||||
// ── Choice type ── present all options to the player
|
// ── Choice type ── present all options to the player
|
||||||
if (d30Message.type === "choice") {
|
if (d30Message.type === "choice") {
|
||||||
// Try to find a bonus_dice option matching this side
|
// If we can't show dialogs (wrong client), skip — the primary client
|
||||||
const autoBonus = d30Message.choices.find(c => c.type === "bonus_dice" && validTargets.includes(c.target))
|
// will communicate its choice result via socket. Auto-rolling here
|
||||||
|
// would give a different modifier on each client, causing divergence.
|
||||||
// If we can't show dialogs (wrong client), auto-roll bonus dice if available
|
|
||||||
if (!canDialog) {
|
if (!canDialog) {
|
||||||
if (autoBonus) {
|
return { modifier: 0, specialEffect: null, specialName: null }
|
||||||
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" }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const buttons = d30Message.choices.map(c => {
|
const buttons = d30Message.choices.map(c => {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
{{formField systemFields.damageType.fields.typeS value=system.damageType.typeS}}
|
{{formField systemFields.damageType.fields.typeS value=system.damageType.typeS}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{formField systemFields.damage.fields.damageM value=system.damage.damageM label="LETHALFANTASY.Label.damage"}}
|
{{formField systemFields.damage.fields.damageM value=system.damage.damageM label=(localize "LETHALFANTASY.Label.damage")}}
|
||||||
|
|
||||||
{{formField systemFields.applyStrengthDamageBonus value=system.applyStrengthDamageBonus localize=true}}
|
{{formField systemFields.applyStrengthDamageBonus value=system.applyStrengthDamageBonus localize=true}}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user