bb005ee9fc
- Remove forceNoD30 from rerollConfiguredRoll so mulligan rerolls the D30 along with the d20 and modifier - Reset defenseD30Processed/attackD30Processed after mulligan so the new D30's effects (bonus dice, specials) are applied - Render reroll dice breakdown (diceResults + D30 result) as inline HTML in the reaction chat message using existing CSS classes so players see what was rolled
1705 lines
66 KiB
JavaScript
1705 lines
66 KiB
JavaScript
import { SYSTEM } from "./config/system.mjs"
|
|
|
|
export function log(...args) {
|
|
if (game?.settings?.get(game.system.id, "debug")) {
|
|
console.log(...args)
|
|
}
|
|
}
|
|
|
|
export default class LethalFantasyUtils {
|
|
|
|
/* -------------------------------------------- */
|
|
static async loadCompendiumData(compendium) {
|
|
const pack = game.packs.get(compendium)
|
|
return await pack?.getDocuments() ?? []
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
static async loadCompendium(compendium, filter = item => true) {
|
|
let compendiumData = await LethalFantasyUtils.loadCompendiumData(compendium)
|
|
return compendiumData.filter(filter)
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
static pushCombatOptions(html, options) {
|
|
options.push({ name: "Reset Progression", condition: true, icon: '<i class="fas fa-rotate-right"></i>', callback: target => { game.combat.resetProgression(target.data('combatant-id')); } })
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
static setHookListeners() {
|
|
|
|
Hooks.on('renderTokenHUD', async (hud, html, data) => {
|
|
if (html.querySelector(".lethal-hp-loss-hud")) return
|
|
// The token/actor is on the HUD application instance, not the third param.
|
|
// hud.token / hud.object gives the Token (PlaceableObject), which has .actor.
|
|
const hudActor = hud.token?.actor ?? hud.object?.actor
|
|
if (!hudActor) return
|
|
// HP Loss Button (existing)
|
|
const lossHPButton = await foundry.applications.handlebars.renderTemplate('systems/fvtt-lethal-fantasy/templates/loss-hp-hud.hbs', {})
|
|
$(html).find('div.left').append(lossHPButton);
|
|
$(html).find('img.lethal-hp-loss-hud').click((event) => {
|
|
event.preventDefault();
|
|
let hpMenu = $(html).find('.hp-loss-wrap')[0]
|
|
if (hpMenu.classList.contains("hp-loss-hud-disabled")) {
|
|
$(html).find('.hp-loss-wrap')[0].classList.add('hp-loss-hud-active');
|
|
$(html).find('.hp-loss-wrap')[0].classList.remove('hp-loss-hud-disabled');
|
|
$(html).find('.hp-loss-wrap')[1].classList.add('hp-loss-hud-active');
|
|
$(html).find('.hp-loss-wrap')[1].classList.remove('hp-loss-hud-disabled');
|
|
$(html).find('.hp-loss-wrap')[2].classList.add('hp-loss-hud-active');
|
|
$(html).find('.hp-loss-wrap')[2].classList.remove('hp-loss-hud-disabled');
|
|
} else {
|
|
$(html).find('.hp-loss-wrap')[0].classList.remove('hp-loss-hud-active');
|
|
$(html).find('.hp-loss-wrap')[0].classList.add('hp-loss-hud-disabled');
|
|
$(html).find('.hp-loss-wrap')[1].classList.remove('hp-loss-hud-active');
|
|
$(html).find('.hp-loss-wrap')[1].classList.add('hp-loss-hud-disabled');
|
|
$(html).find('.hp-loss-wrap')[2].classList.remove('hp-loss-hud-active');
|
|
$(html).find('.hp-loss-wrap')[2].classList.add('hp-loss-hud-disabled');
|
|
}
|
|
})
|
|
$(html).find('.loss-hp-hud-click').click(async (event) => {
|
|
event.preventDefault();
|
|
let hpLoss = event.currentTarget.dataset.hpValue;
|
|
await hudActor.applyDamage(Number(hpLoss));
|
|
$(html).find('.hp-loss-wrap')[0].classList.remove('hp-loss-hud-active');
|
|
$(html).find('.hp-loss-wrap')[0].classList.add('hp-loss-hud-disabled');
|
|
$(html).find('.hp-loss-wrap')[1].classList.remove('hp-loss-hud-active');
|
|
$(html).find('.hp-loss-wrap')[1].classList.add('hp-loss-hud-disabled');
|
|
$(html).find('.hp-loss-wrap')[2].classList.remove('hp-loss-hud-active');
|
|
$(html).find('.hp-loss-wrap')[2].classList.add('hp-loss-hud-disabled');
|
|
})
|
|
|
|
// HP Gain Button (new)
|
|
const gainHPButton = await foundry.applications.handlebars.renderTemplate('systems/fvtt-lethal-fantasy/templates/gain-hp-hud.hbs', {})
|
|
$(html).find('div.left').append(gainHPButton);
|
|
$(html).find('img.lethal-hp-gain-hud').click((event) => {
|
|
event.preventDefault();
|
|
let hpMenu = $(html).find('.hp-gain-wrap')[0]
|
|
if (hpMenu.classList.contains("hp-gain-hud-disabled")) {
|
|
$(html).find('.hp-gain-wrap')[0].classList.add('hp-gain-hud-active');
|
|
$(html).find('.hp-gain-wrap')[0].classList.remove('hp-gain-hud-disabled');
|
|
$(html).find('.hp-gain-wrap')[1].classList.add('hp-gain-hud-active');
|
|
$(html).find('.hp-gain-wrap')[1].classList.remove('hp-gain-hud-disabled');
|
|
$(html).find('.hp-gain-wrap')[2].classList.add('hp-gain-hud-active');
|
|
$(html).find('.hp-gain-wrap')[2].classList.remove('hp-gain-hud-disabled');
|
|
} else {
|
|
$(html).find('.hp-gain-wrap')[0].classList.remove('hp-gain-hud-active');
|
|
$(html).find('.hp-gain-wrap')[0].classList.add('hp-gain-hud-disabled');
|
|
$(html).find('.hp-gain-wrap')[1].classList.remove('hp-gain-hud-active');
|
|
$(html).find('.hp-gain-wrap')[1].classList.add('hp-gain-hud-disabled');
|
|
$(html).find('.hp-gain-wrap')[2].classList.remove('hp-gain-hud-active');
|
|
$(html).find('.hp-gain-wrap')[2].classList.add('hp-gain-hud-disabled');
|
|
}
|
|
})
|
|
$(html).find('.gain-hp-hud-click').click(async (event) => {
|
|
event.preventDefault();
|
|
let hpGain = event.currentTarget.dataset.hpValue;
|
|
await hudActor.applyDamage(Number(hpGain)); // Positive value to add HP
|
|
// Clear bleeding wounds on heal — regardless of heal amount, any
|
|
// healing is enough to stop bleeding (field dressing / magic / rest).
|
|
const wounds = foundry.utils.duplicate(hudActor.system.hp.wounds || [])
|
|
const hadBleeding = wounds.some(w => w.description === "Bleeding")
|
|
if (hadBleeding) {
|
|
await hudActor.update({
|
|
"system.hp.wounds": wounds.map(w =>
|
|
w.description === "Bleeding" ? { value: 0, duration: 0 } : w
|
|
)
|
|
})
|
|
}
|
|
$(html).find('.hp-gain-wrap')[0].classList.remove('hp-gain-hud-active');
|
|
$(html).find('.hp-gain-wrap')[0].classList.add('hp-gain-hud-disabled');
|
|
$(html).find('.hp-gain-wrap')[1].classList.remove('hp-gain-hud-active');
|
|
$(html).find('.hp-gain-wrap')[1].classList.add('hp-gain-hud-disabled');
|
|
$(html).find('.hp-gain-wrap')[2].classList.remove('hp-gain-hud-active');
|
|
$(html).find('.hp-gain-wrap')[2].classList.add('hp-gain-hud-disabled');
|
|
})
|
|
|
|
// Luck/Grit Buttons
|
|
const luckGritButton = await foundry.applications.handlebars.renderTemplate('systems/fvtt-lethal-fantasy/templates/luck-grit-hud.hbs', {})
|
|
$(html).find('div.left').append(luckGritButton);
|
|
$(html).find('.lethal-luck-grit-hud').click((event) => {
|
|
event.preventDefault();
|
|
let wrap = $(html).find('.luck-grit-wrap')[0]
|
|
if (wrap.classList.contains("luck-grit-hud-disabled")) {
|
|
wrap.classList.add('luck-grit-hud-active');
|
|
wrap.classList.remove('luck-grit-hud-disabled');
|
|
} else {
|
|
wrap.classList.remove('luck-grit-hud-active');
|
|
wrap.classList.add('luck-grit-hud-disabled');
|
|
}
|
|
})
|
|
$(html).find('.luck-grit-btn').click(async (event) => {
|
|
event.preventDefault();
|
|
const resource = event.currentTarget.dataset.resource;
|
|
const amount = Number(event.currentTarget.dataset.amount);
|
|
const current = Number(foundry.utils.getProperty(hudActor.system, `${resource}.current`)) || 0;
|
|
const newValue = Math.max(0, current + amount);
|
|
await hudActor.update({ [`system.${resource}.current`]: newValue });
|
|
$(html).find('.luck-grit-wrap')[0].classList.remove('luck-grit-hud-active');
|
|
$(html).find('.luck-grit-wrap')[0].classList.add('luck-grit-hud-disabled');
|
|
})
|
|
})
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
static async handleSocketEvent(msg = {}) {
|
|
log(`handleSocketEvent !`, msg)
|
|
let actor
|
|
switch (msg.type) {
|
|
case "applyDamage":
|
|
if (game.user.isGM) {
|
|
// Prefer the specific token actor (correct for unlinked monsters); fall back to world actor.
|
|
actor = msg.tokenId
|
|
? canvas.tokens?.placeables?.find(t => t.id === msg.tokenId)?.actor
|
|
: (game.combat?.combatants?.find(c => c.actorId === msg.actorId)?.actor
|
|
?? game.actors.get(msg.actorId))
|
|
if (actor) await actor.applyDamage(msg.damage)
|
|
}
|
|
break
|
|
case "rollInitiative":
|
|
if (msg.userId && msg.userId !== game.user.id) break
|
|
actor = game.actors.get(msg.actorId)
|
|
await actor.system.rollInitiative(msg.combatId, msg.combatantId)
|
|
break
|
|
case "rollProgressionDice":
|
|
if (msg.userId && msg.userId !== game.user.id) break
|
|
actor = game.actors.get(msg.actorId)
|
|
await actor.system.rollProgressionDice(msg.combatId, msg.combatantId, msg.rollProgressionCount)
|
|
break
|
|
case "requestDefense":
|
|
// Vérifier si le message est destiné à cet utilisateur
|
|
if (msg.userId === game.user.id) {
|
|
LethalFantasyUtils.showDefenseRequest(msg)
|
|
}
|
|
break
|
|
case "offerAttackerGrit":
|
|
// Vérifier si le message est destiné à cet utilisateur
|
|
if (msg.userId === game.user.id) {
|
|
LethalFantasyUtils.handleAttackerGritOffer(msg)
|
|
}
|
|
break
|
|
case "applyBleeding":
|
|
if (game.user.isGM) {
|
|
actor = msg.tokenId
|
|
? canvas.tokens?.placeables?.find(t => t.id === msg.tokenId)?.actor
|
|
: game.actors.get(msg.actorId)
|
|
if (actor && actor.system.hp?.wounds && msg.damage > 0) {
|
|
const wounds = foundry.utils.duplicate(actor.system.hp.wounds)
|
|
const slot = wounds.findIndex(w => !w.value && !w.duration)
|
|
if (slot !== -1) {
|
|
wounds[slot] = { value: msg.damage, duration: msg.damage, description: "Bleeding" }
|
|
await actor.update({ "system.hp.wounds": wounds })
|
|
}
|
|
}
|
|
}
|
|
break
|
|
case "attackBoosted":
|
|
if (msg.userId === game.user.id) {
|
|
LethalFantasyUtils.handleAttackBoosted(msg)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
static async handleAttackerGritOffer(msg) {
|
|
const { attackerId, attackRoll, defenseRoll, attackerName, defenderName, attackWeaponId, attackRollType, attackRollKey, defenderId } = msg
|
|
|
|
const attacker = game.actors.get(attackerId)
|
|
if (!attacker) {
|
|
console.warn("Attacker not found:", attackerId)
|
|
return
|
|
}
|
|
|
|
const attackBonus = await LethalFantasyUtils.offerAttackerGritBonus(
|
|
attacker,
|
|
attackRoll,
|
|
defenseRoll,
|
|
attackerName,
|
|
defenderName
|
|
)
|
|
|
|
const attackRollFinal = attackRoll + attackBonus
|
|
|
|
// Maintenant créer le message de comparaison
|
|
await LethalFantasyUtils.compareAttackDefense({
|
|
attackerName,
|
|
attackerId,
|
|
attackRoll: attackRollFinal,
|
|
attackWeaponId,
|
|
attackRollType,
|
|
attackRollKey,
|
|
defenderName,
|
|
defenderId,
|
|
defenseRoll
|
|
})
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
static async handleAttackBoosted(msg) {
|
|
const {
|
|
attackerName, attackerId, defenderName, defenderId, defenderTokenId,
|
|
attackerHandledBonus, attackRollFinal, defenseRoll, attackWeaponId, attackRollType, attackRollKey,
|
|
shieldDamageReduction: initialShieldDR,
|
|
d30Bleed, d30DamageMultiplier, d30DrMultiplier,
|
|
damageTier, attackD30message, defenseD30message,
|
|
hasShield, shieldLabel, shieldFormula, shieldDr, canAdHocShield
|
|
} = msg
|
|
|
|
const defender = game.actors.get(defenderId)
|
|
if (!defender) return
|
|
|
|
let updatedDefenseRoll = defenseRoll
|
|
let shieldBlocked = false
|
|
let shieldReaction = null
|
|
let canShieldReact = hasShield
|
|
let canAdHoc = canAdHocShield
|
|
|
|
// ── 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: `<p><strong>${defenderName}</strong> gains <strong>+${d30Result.modifier}</strong> from D30 bonus die for defense.</p>`,
|
|
speaker: ChatMessage.getSpeaker({ actor: defender })
|
|
})
|
|
}
|
|
}
|
|
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") {
|
|
await ChatMessage.create({
|
|
content: `<p>D30 — <strong>${d30Result.specialName || "Special Effect"}</strong> triggered for ${defenderName}!</p>`,
|
|
speaker: ChatMessage.getSpeaker({ actor: defender })
|
|
})
|
|
}
|
|
if (d30Result.specialEffect === "drMultiplier") {
|
|
defenseDrMultiplier = d30Result.multiplier
|
|
await ChatMessage.create({
|
|
content: `<p>D30 — Defense grants <strong>x${d30Result.multiplier} DR</strong> (choose which DR types to multiply when damage is applied)</p>`,
|
|
speaker: ChatMessage.getSpeaker({ actor: defender })
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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 = []
|
|
|
|
if (currentGrit > 0) {
|
|
buttons.push({
|
|
action: "grit",
|
|
type: "button",
|
|
label: `Spend 1 Grit (+1D6) [${currentGrit} left]`,
|
|
icon: "fa-solid fa-fist-raised",
|
|
callback: () => "grit"
|
|
})
|
|
}
|
|
|
|
if (currentLuck > 0) {
|
|
buttons.push({
|
|
action: "luck",
|
|
type: "button",
|
|
label: `Spend 1 Luck (+1D6) [${currentLuck} left]`,
|
|
icon: "fa-solid fa-clover",
|
|
callback: () => "luck"
|
|
})
|
|
}
|
|
|
|
buttons.push({
|
|
action: "bonusDie",
|
|
type: "button",
|
|
label: "Add bonus die",
|
|
icon: "fa-solid fa-dice",
|
|
callback: () => "bonusDie"
|
|
})
|
|
|
|
if (canShieldReact) {
|
|
buttons.push({
|
|
action: "shieldReact",
|
|
type: "button",
|
|
label: `Roll shield (${shieldLabel})`,
|
|
icon: "fa-solid fa-shield",
|
|
callback: () => "shieldReact"
|
|
})
|
|
} else if (canAdHoc) {
|
|
buttons.push({
|
|
action: "adHocShield",
|
|
type: "button",
|
|
label: "Roll ad-hoc shield (choose dice + DR)",
|
|
icon: "fa-solid fa-shield-halved",
|
|
callback: () => "adHocShield"
|
|
})
|
|
}
|
|
|
|
buttons.push({
|
|
action: "continue",
|
|
type: "button",
|
|
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>
|
|
`,
|
|
buttons,
|
|
rejectClose: false
|
|
})
|
|
|
|
if (!choice || choice === "continue") break
|
|
|
|
if (choice === "grit") {
|
|
const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender,
|
|
total => `<p><strong>${defenderName}</strong> spends 1 Grit and rolls <strong>${total}</strong> for defense.</p>`)
|
|
updatedDefenseRoll += bonusRoll
|
|
await defender.update({ "system.grit.current": currentGrit - 1 })
|
|
} 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
|
|
}
|
|
} 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: `<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 })
|
|
})
|
|
} else {
|
|
await ChatMessage.create({
|
|
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
|
|
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 outcome = shieldBlocked ? "shielded-hit" : (updatedDefenseRoll >= attackRollFinal ? "miss" : "hit")
|
|
|
|
await LethalFantasyUtils.compareAttackDefense({
|
|
attackerName,
|
|
attackerId,
|
|
attackRoll: attackRollFinal,
|
|
attackWeaponId,
|
|
attackRollType,
|
|
attackRollKey,
|
|
defenderName,
|
|
defenderId,
|
|
defenderTokenId,
|
|
defenseRoll: updatedDefenseRoll,
|
|
outcome,
|
|
shieldDamageReduction: finalShieldDR,
|
|
d30Bleed: d30Bleed || "",
|
|
d30DamageMultiplier: d30DamageMultiplier || 1,
|
|
d30DrMultiplier: (d30DrMultiplier != null && d30DrMultiplier !== 1) ? d30DrMultiplier : (defenseDrMultiplier ?? d30DrMultiplier ?? 1),
|
|
damageTier: damageTier || "standard",
|
|
attackD30message
|
|
})
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
static async showDefenseRequest(msg) {
|
|
const attackerName = msg.attackerName
|
|
const attackerId = msg.attackerId
|
|
const defenderName = msg.defenderName
|
|
const weaponName = msg.weaponName || "attack"
|
|
const attackRoll = msg.attackRoll
|
|
const attackWeaponId = msg.attackWeaponId
|
|
const attackRollType = msg.attackRollType
|
|
const attackRollKey = msg.attackRollKey
|
|
const attackD30result = msg.attackD30result
|
|
const attackD30message = msg.attackD30message
|
|
const attackRerollContext = msg.attackRerollContext
|
|
const combatantId = msg.combatantId
|
|
const tokenId = msg.tokenId
|
|
|
|
// Récupérer le défenseur - essayer d'abord depuis le combat, puis depuis le token
|
|
let defender = null
|
|
|
|
if (game.combat && combatantId) {
|
|
const combatant = game.combat.combatants.get(combatantId)
|
|
if (combatant) {
|
|
defender = combatant.actor
|
|
}
|
|
}
|
|
|
|
// Si pas trouvé dans le combat, chercher le token directement
|
|
if (!defender && tokenId) {
|
|
const token = canvas.tokens.get(tokenId)
|
|
if (token) {
|
|
defender = token.actor
|
|
}
|
|
}
|
|
|
|
if (!defender) {
|
|
ui.notifications.error("Defender actor not found")
|
|
return
|
|
}
|
|
|
|
// Resolve the specific token ID now while we still have combatant/token context.
|
|
// This is passed through to the damage roll so the GM-side socket handler can find the
|
|
// correct synthetic actor for unlinked tokens (avoids wrong-instance damage with multiple
|
|
// unlinked copies of the same monster).
|
|
const defenderTokenId = (() => {
|
|
if (game.combat && combatantId) {
|
|
const cbt = game.combat.combatants.get(combatantId)
|
|
if (cbt?.token?.id) return cbt.token.id
|
|
}
|
|
return tokenId ?? canvas.tokens?.placeables?.find(t => t.actor?.id === defender.id)?.id ?? null
|
|
})()
|
|
|
|
const isMonster = defender.type === "monster"
|
|
|
|
log(`[LF] showDefenseRequest | attackRollType=${attackRollType} isMonster=${isMonster} defender=${defender?.name}`)
|
|
|
|
// Spell/miracle attacks use saving throws instead of weapon defense
|
|
const isSpellAttack = attackRollType === "spell-attack" || attackRollType === "miracle-attack"
|
|
if (isSpellAttack) {
|
|
const savesConfig = isMonster ? SYSTEM.MONSTER_SAVES : SYSTEM.SAVES
|
|
const combatSaves = ["will", "dodge", "toughness"]
|
|
const savesHTML = Object.values(savesConfig)
|
|
.filter(s => combatSaves.includes(s.id))
|
|
.map(s => `<option value="${s.id}">${game.i18n.localize(s.label)}</option>`)
|
|
.join("")
|
|
|
|
const content = `
|
|
<div class="defense-request-dialog">
|
|
<div class="attack-info">
|
|
<p><strong>${attackerName}</strong> targets <strong>${defenderName}</strong> with <strong>${weaponName}</strong>!</p>
|
|
<p>Attack roll: <strong>${attackRoll}</strong></p>
|
|
</div>
|
|
<div class="weapon-selection">
|
|
<label for="save-type">Choose saving throw:</label>
|
|
<select id="save-type" name="saveKey" style="width: 100%; margin-top: 8px;">
|
|
${savesHTML}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
`
|
|
|
|
const result = await foundry.applications.api.DialogV2.wait({
|
|
window: { title: "Saving Throw vs Spell" },
|
|
classes: ["lethalfantasy"],
|
|
content,
|
|
buttons: [
|
|
{
|
|
action: "rollSave",
|
|
type: "button",
|
|
label: "Roll Save",
|
|
icon: "fa-solid fa-person-running",
|
|
callback: (event, button) => button.form.elements.saveKey.value,
|
|
},
|
|
],
|
|
rejectClose: false
|
|
})
|
|
|
|
if (result) {
|
|
game.lethalFantasy = game.lethalFantasy || {}
|
|
game.lethalFantasy.spellDefense = true // pré-cocher "Save against spell" dans le dialog
|
|
game.lethalFantasy.nextDefenseData = {
|
|
attackerId,
|
|
attackRoll,
|
|
attackerName,
|
|
defenderName,
|
|
attackWeaponId,
|
|
attackRollType,
|
|
attackRollKey,
|
|
attackD30result,
|
|
attackD30message,
|
|
attackRerollContext,
|
|
attackNaturalRoll: msg.attackNaturalRoll,
|
|
damageTier: msg.damageTier,
|
|
defenderId: defender.id,
|
|
defenderTokenId
|
|
}
|
|
if (isMonster) {
|
|
await defender.system.prepareMonsterRoll("save", result)
|
|
} else {
|
|
await defender.prepareRoll("save", result)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// Pour les monstres, récupérer les attaques activées
|
|
if (isMonster) {
|
|
const attacksSet = defender.system.attackMode === "ranged" ? defender.system.rangedAttacks : defender.system.attacks
|
|
const enabledAttacks = Object.entries(attacksSet).filter(([key, attack]) => attack.enabled)
|
|
|
|
if (enabledAttacks.length === 0) {
|
|
ui.notifications.warn("No enabled attacks available for defense")
|
|
return
|
|
}
|
|
|
|
// Créer le contenu du dialogue pour monstre
|
|
let attacksHTML = enabledAttacks.map(([key, attack]) =>
|
|
`<option value="${key}">${attack.name}</option>`
|
|
).join("")
|
|
|
|
const content = `
|
|
<div class="defense-request-dialog">
|
|
<div class="attack-info">
|
|
<p><strong>${attackerName}</strong> attacks <strong>${defenderName}</strong> with <strong>${weaponName}</strong>!</p>
|
|
<p>Attack roll: <strong>${attackRoll}</strong></p>
|
|
</div>
|
|
<div class="weapon-selection">
|
|
<label for="defense-attack">Choose your defense weapon:</label>
|
|
<select id="defense-attack" name="attackKey" style="width: 100%; margin-top: 8px;">
|
|
${attacksHTML}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
`
|
|
|
|
// Afficher le dialogue
|
|
const result = await foundry.applications.api.DialogV2.wait({
|
|
window: { title: msg.isRanged ? "Ranged Defense Roll" : "Defense Roll" },
|
|
classes: ["lethalfantasy"],
|
|
content,
|
|
buttons: [
|
|
{
|
|
action: "rangeDefense",
|
|
type: "button",
|
|
label: "Roll Defense",
|
|
icon: "fa-solid fa-shield",
|
|
callback: (event, button, dialog) => {
|
|
const attackKey = button.form.elements.attackKey.value
|
|
return attackKey
|
|
},
|
|
},
|
|
],
|
|
rejectClose: false
|
|
})
|
|
|
|
// Si l'utilisateur a validé, lancer le jet de défense
|
|
if (result) {
|
|
// Stocker temporairement les données pour le hook preCreateChatMessage
|
|
game.lethalFantasy = game.lethalFantasy || {}
|
|
game.lethalFantasy.nextDefenseData = {
|
|
attackerId,
|
|
attackRoll,
|
|
attackerName,
|
|
defenderName,
|
|
attackWeaponId,
|
|
attackRollType,
|
|
attackRollKey,
|
|
attackD30result,
|
|
attackD30message,
|
|
attackRerollContext,
|
|
attackNaturalRoll: msg.attackNaturalRoll,
|
|
damageTier: msg.damageTier,
|
|
defenderId: defender.id,
|
|
defenderTokenId,
|
|
isRanged: msg.isRanged
|
|
}
|
|
|
|
await defender.system.prepareMonsterRoll("monster-defense", result)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Pour les personnages, récupérer les armes équipées
|
|
// Si l'attaque est une attaque à distance, utiliser le dialogue de défense à distance
|
|
if (msg.isRanged) {
|
|
const { default: LethalFantasyRoll } = await import("./documents/roll.mjs")
|
|
const roll = await LethalFantasyRoll.promptRangedDefense({
|
|
actorId: defender.id,
|
|
actorName: defender.name,
|
|
actorImage: defender.img,
|
|
})
|
|
if (roll) {
|
|
game.lethalFantasy = game.lethalFantasy || {}
|
|
game.lethalFantasy.nextDefenseData = {
|
|
attackerId,
|
|
attackRoll,
|
|
attackerName,
|
|
defenderName,
|
|
attackWeaponId,
|
|
attackRollType,
|
|
attackRollKey,
|
|
attackD30result,
|
|
attackD30message,
|
|
attackRerollContext,
|
|
damageTier: msg.damageTier,
|
|
defenderId: defender.id,
|
|
defenderTokenId,
|
|
isRanged: true
|
|
}
|
|
await roll.toMessage({}, { messageMode: roll.options.rollMode })
|
|
}
|
|
return
|
|
}
|
|
|
|
// Pour les personnages, récupérer les armes équipées
|
|
const equippedWeapons = defender.items.filter(i =>
|
|
i.type === "weapon" && i.system.equipped === true
|
|
)
|
|
|
|
if (equippedWeapons.length === 0) {
|
|
ui.notifications.warn("No equipped weapons for defense")
|
|
return
|
|
}
|
|
|
|
// Créer le contenu du dialogue pour personnage
|
|
let weaponsHTML = equippedWeapons.map(w =>
|
|
`<option value="${w.id}">${w.name}</option>`
|
|
).join("")
|
|
|
|
const content = `
|
|
<div class="defense-request-dialog">
|
|
<div class="attack-info">
|
|
<p><strong>${attackerName}</strong> attacks <strong>${defenderName}</strong> with <strong>${weaponName}</strong>!</p>
|
|
<p>Attack roll: <strong>${attackRoll}</strong></p>
|
|
</div>
|
|
<div class="weapon-selection">
|
|
<label for="defense-weapon">Choose your defense weapon:</label>
|
|
<select id="defense-weapon" name="weaponId" style="width: 100%; margin-top: 8px;">
|
|
${weaponsHTML}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
`
|
|
|
|
// Afficher le dialogue
|
|
const result = await foundry.applications.api.DialogV2.wait({
|
|
window: { title: "Defense Roll" },
|
|
classes: ["lethalfantasy"],
|
|
content,
|
|
buttons: [
|
|
{
|
|
action: "defenseRoll",
|
|
type: "button",
|
|
label: "Roll Defense",
|
|
icon: "fa-solid fa-shield",
|
|
callback: (event, button, dialog) => {
|
|
const weaponId = button.form.elements.weaponId.value
|
|
return weaponId
|
|
},
|
|
},
|
|
],
|
|
rejectClose: false
|
|
})
|
|
|
|
// Si l'utilisateur a validé, lancer le jet de défense
|
|
if (result) {
|
|
// Stocker temporairement les données pour le hook preCreateChatMessage
|
|
game.lethalFantasy = game.lethalFantasy || {}
|
|
game.lethalFantasy.nextDefenseData = {
|
|
attackerId,
|
|
attackRoll,
|
|
attackerName,
|
|
defenderName,
|
|
attackWeaponId,
|
|
attackRollType,
|
|
attackRollKey,
|
|
attackD30result,
|
|
attackD30message,
|
|
attackRerollContext,
|
|
attackNaturalRoll: msg.attackNaturalRoll,
|
|
damageTier: msg.damageTier,
|
|
defenderId: defender.id,
|
|
defenderTokenId,
|
|
isRanged: msg.isRanged
|
|
}
|
|
|
|
log("Storing defense data for character:", defender.id)
|
|
|
|
await defender.prepareRoll("weapon-defense", result)
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
static hasD30Reroll(d30Message) {
|
|
return d30Message?.type === "mulligan"
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
/**
|
|
* Process D30 bonus dice for attack or defense.
|
|
* Rolls and applies bonus dice BEFORE grit/luck/shield decisions.
|
|
* For `choice` type results (D30=20, 30), shows dialog to choose between bonus dice and special effect.
|
|
* For `bonus_dice` type results (D30=27, 2, 3), auto-rolls the dice.
|
|
* @param {Object|null} d30Message The D30 result object
|
|
* @param {"attack"|"defense"} side Whether processing the attack or defense side
|
|
* @param {number|null} naturalRoll The natural D20 roll (for special strike type detection)
|
|
* @param {Object} actor The actor (for dice3d display)
|
|
* @returns {Promise<{modifier: number, specialEffect: string|null, specialName: string|null}>}
|
|
*/
|
|
static async processD30BonusDice(d30Message, side, naturalRoll = null, actor = null, canDialog = true) {
|
|
if (!d30Message) return { modifier: 0, specialEffect: null, specialName: null }
|
|
|
|
const validTargets = side === "attack" ? ["attack", "spell_attack"] : ["defense", "spell_defense"]
|
|
|
|
// ── Simple bonus_dice type ── auto-roll if target matches
|
|
if (d30Message.type === "bonus_dice") {
|
|
if (!validTargets.includes(d30Message.target)) return { modifier: 0, specialEffect: null, specialName: null }
|
|
const modifier = await this._rollD30BonusDie(d30Message.dice, actor, !canDialog)
|
|
return { modifier, specialEffect: null, specialName: null }
|
|
}
|
|
|
|
// ── Choice type ── present all options to the player
|
|
if (d30Message.type === "choice") {
|
|
// 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) {
|
|
return { modifier: 0, specialEffect: null, specialName: null }
|
|
}
|
|
|
|
const buttons = d30Message.choices.map(c => {
|
|
let label
|
|
let icon
|
|
if (c.type === "bonus_dice") {
|
|
label = `Roll ${c.dice.toUpperCase()} and add to ${side}`
|
|
icon = "fa-solid fa-dice"
|
|
} else if (c.type === "special_strike") {
|
|
label = this._buildSpecialLabel(c, naturalRoll)
|
|
icon = "fa-solid fa-star"
|
|
} else if (c.type === "special_defense") {
|
|
label = this._buildSpecialLabel(c, naturalRoll)
|
|
icon = "fa-solid fa-shield-halved"
|
|
} else {
|
|
label = c.type.replace(/_/g, " ").replace(/\b\w/g, l => l.toUpperCase())
|
|
icon = "fa-solid fa-question"
|
|
}
|
|
return {
|
|
action: c.type,
|
|
type: "button",
|
|
label,
|
|
icon,
|
|
callback: () => c
|
|
}
|
|
})
|
|
|
|
const choice = await foundry.applications.api.DialogV2.wait({
|
|
window: { title: "D30 Special — Choose Effect" },
|
|
classes: ["lethalfantasy"],
|
|
content: `
|
|
<div class="grit-luck-dialog">
|
|
<p><strong>D30 result:</strong> ${d30Message.description}</p>
|
|
<p>Choose how to use this result:</p>
|
|
</div>
|
|
`,
|
|
buttons,
|
|
rejectClose: false
|
|
})
|
|
|
|
if (!choice) return { modifier: 0, specialEffect: null, specialName: null }
|
|
|
|
if (choice.type === "bonus_dice") {
|
|
const modifier = await this._rollD30BonusDie(choice.dice, actor)
|
|
return { modifier, specialEffect: null, specialName: null }
|
|
}
|
|
|
|
if (choice.type === "special_strike" || choice.type === "special_defense") {
|
|
return { modifier: 0, specialEffect: "auto", specialName: this._buildSpecialName(choice, naturalRoll) }
|
|
}
|
|
|
|
// Non-standard choice (spell_calamity, etc.) — report it
|
|
return { modifier: 0, specialEffect: "flag", specialName: choice.type }
|
|
}
|
|
|
|
// ── Combo type (bleed / internal injury) — flag for wound creation
|
|
if (d30Message.type === "combo") {
|
|
const hasBleed = d30Message.effects?.some(e => e.type === "bleed" || e.type === "internal_injury")
|
|
if (hasBleed) {
|
|
return { modifier: 0, specialEffect: "bleed", specialName: "Bleeding/Internal Injury" }
|
|
}
|
|
}
|
|
|
|
// ── Damage multiplier type (2x/3x damage before DR)
|
|
if (d30Message.type === "damage_multiplier") {
|
|
return { modifier: 0, specialEffect: "damageMultiplier", specialName: `x${d30Message.multiplier} Damage`, multiplier: d30Message.multiplier }
|
|
}
|
|
|
|
// ── DR multiplier type (2x/3x DR including shield)
|
|
if (d30Message.type === "dr_multiplier") {
|
|
return { modifier: 0, specialEffect: "drMultiplier", specialName: `x${d30Message.multiplier} DR`, multiplier: d30Message.multiplier }
|
|
}
|
|
|
|
return { modifier: 0, specialEffect: null, specialName: null }
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
/**
|
|
* Roll a D30 bonus die and show with 3D dice if available.
|
|
* @param {string} formula Dice formula (e.g. "D6", "D12", "D20E")
|
|
* @param {Object} actor Actor for chat message speaker
|
|
* @returns {Promise<number>} The roll total
|
|
*/
|
|
static async _rollD30BonusDie(formula, actor, silent = false) {
|
|
const cleaned = formula.replace(/NE$/i, "").replace("E", "")
|
|
const roll = new Roll(cleaned)
|
|
await roll.evaluate()
|
|
if (game?.dice3d) {
|
|
await game.dice3d.showForRoll(roll, game.user, true)
|
|
}
|
|
if (!silent) {
|
|
await ChatMessage.create({
|
|
content: `<p>D30 bonus: rolled <strong>${cleaned.toUpperCase()}</strong> = <strong>${roll.total}</strong></p>`,
|
|
speaker: ChatMessage.getSpeaker({ actor })
|
|
})
|
|
}
|
|
return roll.total
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
/**
|
|
* Build a human-readable label for a special strike/defense choice in the D30 prompt.
|
|
* @param {Object} specialChoice The choice object with type and options
|
|
* @param {number|null} naturalRoll The natural D20 roll
|
|
* @returns {string} Display label
|
|
*/
|
|
static _buildSpecialLabel(specialChoice, naturalRoll) {
|
|
if (specialChoice.type === "special_strike") {
|
|
if (specialChoice.options.includes("lethal")) {
|
|
if (naturalRoll === 20) return "Lethal Strike (auto-hit)"
|
|
if (naturalRoll >= 16 && naturalRoll <= 19) return "Vital Strike (auto-hit)"
|
|
return "Lethal/Vital Strike (auto-hit)"
|
|
}
|
|
if (specialChoice.options.includes("vicious")) return "Vicious Strike (auto-hit)"
|
|
return "Special Strike (auto-hit)"
|
|
}
|
|
if (specialChoice.type === "special_defense") {
|
|
if (specialChoice.options.includes("perfect_spell")) return "Perfect Spell Defense (auto-block)"
|
|
if (specialChoice.options.includes("flawless")) return "Flawless Defense (auto-block)"
|
|
if (specialChoice.options.includes("legendary")) return "Legendary Defense (auto-block)"
|
|
if (specialChoice.options.includes("perfect")) return "Perfect Defense (auto-block)"
|
|
return "Special Defense (auto-block)"
|
|
}
|
|
return "Special Effect"
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
/**
|
|
* Build the special effect name based on the D30 result and natural roll.
|
|
* @param {Object} specialChoice The choice object with type and options
|
|
* @param {number|null} naturalRoll The natural D20 roll
|
|
* @returns {string} The special effect name
|
|
*/
|
|
static _buildSpecialName(specialChoice, naturalRoll) {
|
|
if (specialChoice.type === "special_strike") {
|
|
if (specialChoice.options.includes("lethal")) {
|
|
if (naturalRoll === 20) return "Lethal Strike"
|
|
if (naturalRoll >= 16 && naturalRoll <= 19) return "Vital Strike"
|
|
return "Lethal/Vital Strike"
|
|
}
|
|
if (specialChoice.options.includes("vicious")) return "Vicious Strike"
|
|
return "Special Strike"
|
|
}
|
|
if (specialChoice.type === "special_defense") {
|
|
if (specialChoice.options.includes("perfect_spell")) return "Perfect Spell Defense"
|
|
if (specialChoice.options.includes("flawless")) return "Flawless Defense"
|
|
if (specialChoice.options.includes("legendary")) return "Legendary Defense"
|
|
if (specialChoice.options.includes("perfect")) return "Perfect Defense"
|
|
return "Special Defense"
|
|
}
|
|
return "Special Effect"
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
static getCombatBonusDiceChoices() {
|
|
return ["1d4", "1d6", "1d8", "1d10", "1d12", "1d20", "1d20e"]
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
static getShieldReactionData(actor) {
|
|
if (!actor) return null
|
|
if (actor.type === "monster") {
|
|
const formula = actor.system.combat?.shieldDefenseDice
|
|
const damageReduction = actor.getShieldDR()
|
|
if (!formula || damageReduction <= 0) return null
|
|
return {
|
|
label: game.i18n.localize("LETHALFANTASY.Label.shieldDefenseDice"),
|
|
formula,
|
|
damageReduction
|
|
}
|
|
}
|
|
|
|
const equippedShields = actor.items.filter(item => item.type === "shield" && item.system.equipped)
|
|
if (equippedShields.length === 0) return null
|
|
|
|
const shield = equippedShields[0]
|
|
return {
|
|
label: shield.name,
|
|
formula: shield.system.defense,
|
|
damageReduction: actor.getShieldDR(),
|
|
shieldId: shield.id
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
static async promptCombatBonusDie(actorName, sideLabel, currentRoll, opposingRoll) {
|
|
const choices = this.getCombatBonusDiceChoices()
|
|
const optionsHtml = choices.map(choice => `<option value="${choice}">${choice.toUpperCase()}</option>`).join("")
|
|
const content = `
|
|
<div class="grit-luck-dialog">
|
|
<div class="combat-status">
|
|
<p><strong>${actorName}</strong> currently has <strong>${currentRoll}</strong></p>
|
|
<p>Opposing ${sideLabel} roll: <strong>${opposingRoll}</strong></p>
|
|
</div>
|
|
<div class="weapon-selection">
|
|
<label for="bonus-die">Choose a bonus die:</label>
|
|
<select id="bonus-die" name="bonusDie" style="width: 100%; margin-top: 8px;">
|
|
${optionsHtml}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
`
|
|
|
|
return await foundry.applications.api.DialogV2.wait({
|
|
window: { title: "Add Bonus Die" },
|
|
classes: ["lethalfantasy"],
|
|
content,
|
|
buttons: [
|
|
{
|
|
action: "roll",
|
|
type: "button",
|
|
label: "Roll Bonus Die",
|
|
icon: "fa-solid fa-dice",
|
|
callback: (event, button) => {
|
|
const sel = button.form?.elements?.bonusDie ?? button.closest("form")?.elements?.bonusDie
|
|
return sel?.value ?? choices[0]
|
|
}
|
|
},
|
|
{
|
|
action: "cancel",
|
|
type: "button",
|
|
label: "Cancel",
|
|
icon: "fa-solid fa-xmark",
|
|
callback: () => null
|
|
}
|
|
],
|
|
rejectClose: false
|
|
})
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
/**
|
|
* Prompt the GM or player to choose an ad-hoc shield dice and DR value.
|
|
* Used when the defender has no pre-configured shield equipment.
|
|
* @param {string} defenderName
|
|
* @param {number} attackRoll
|
|
* @param {number} defenseRoll
|
|
* @returns {Promise<{formula: string, damageReduction: number}|null>}
|
|
*/
|
|
static async promptAdHocShield(defenderName, attackRoll, defenseRoll) {
|
|
const choices = this.getCombatBonusDiceChoices()
|
|
const optionsHtml = choices.map(c => `<option value="${c}">${c.toUpperCase()}</option>`).join("")
|
|
const content = `
|
|
<div class="grit-luck-dialog">
|
|
<div class="combat-status">
|
|
<p><strong>${defenderName}</strong> uses a shield (not equipped)</p>
|
|
<p>Attack: <strong>${attackRoll}</strong> — Current defense: <strong>${defenseRoll}</strong></p>
|
|
</div>
|
|
<div class="weapon-selection" style="margin-top:8px;">
|
|
<label for="shield-dice">Shield dice:</label>
|
|
<select id="shield-dice" name="shieldDice" style="width: 100%; margin-top: 4px;">
|
|
${optionsHtml}
|
|
</select>
|
|
</div>
|
|
<div class="weapon-selection" style="margin-top:8px;">
|
|
<label for="shield-dr">Shield DR value:</label>
|
|
<input id="shield-dr" name="shieldDR" type="number" min="0" value="0" style="width: 100%; margin-top: 4px;" />
|
|
</div>
|
|
</div>
|
|
`
|
|
|
|
const raw = await foundry.applications.api.DialogV2.wait({
|
|
window: { title: "Ad-hoc Shield Roll" },
|
|
classes: ["lethalfantasy"],
|
|
content,
|
|
buttons: [
|
|
{
|
|
action: "roll",
|
|
type: "button",
|
|
label: "Roll Shield",
|
|
icon: "fa-solid fa-shield",
|
|
callback: (event, button) => {
|
|
const shieldDice = button.form?.elements?.shieldDice ?? button.closest("form")?.elements?.shieldDice
|
|
const shieldDR = button.form?.elements?.shieldDR ?? button.closest("form")?.elements?.shieldDR
|
|
return {
|
|
formula: shieldDice?.value ?? "1d6",
|
|
damageReduction: Number(shieldDR?.value) || 0
|
|
}
|
|
}
|
|
},
|
|
{
|
|
action: "cancel",
|
|
type: "button",
|
|
label: "Cancel",
|
|
icon: "fa-solid fa-xmark",
|
|
callback: () => null
|
|
}
|
|
],
|
|
rejectClose: false
|
|
})
|
|
|
|
return raw ?? null
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
/**
|
|
* Roll a bonus die formula, optionally showing Dice So Nice animation and posting a chat message.
|
|
* @param {string} formula
|
|
* @param {Actor} actor
|
|
* @param {Function} [messageContent]
|
|
* @returns {Promise<number>}
|
|
*/
|
|
static async rollBonusDie(formula, actor, messageContent) {
|
|
const roll = new Roll(formula)
|
|
await roll.evaluate()
|
|
if (game?.dice3d) {
|
|
await game.dice3d.showForRoll(roll, game.user, true)
|
|
}
|
|
if (messageContent) {
|
|
await ChatMessage.create({
|
|
content: messageContent(roll.total, formula),
|
|
speaker: ChatMessage.getSpeaker({ actor })
|
|
})
|
|
}
|
|
return roll.total
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
static async rerollConfiguredRoll(rerollContext = {}) {
|
|
const RollClass = CONFIG.Dice.rolls.find(r => r.name === "LethalFantasyRoll")
|
|
if (typeof RollClass?.prompt !== "function") {
|
|
ui.notifications.error("Lethal Fantasy roll class not available for reroll")
|
|
return null
|
|
}
|
|
|
|
return await RollClass.prompt({
|
|
...foundry.utils.duplicate(rerollContext),
|
|
rollContext: foundry.utils.duplicate(rerollContext.rollContext || {}),
|
|
hasTarget: false,
|
|
target: false
|
|
})
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
static async offerGritLuckBonus(defender, attackRoll, currentDefenseRoll, attackerName, defenderName) {
|
|
let totalBonus = 0
|
|
let keepOffering = true
|
|
|
|
while (keepOffering && currentDefenseRoll + totalBonus < attackRoll) {
|
|
const currentGrit = defender.system.grit.current
|
|
const currentLuck = defender.system.luck.current
|
|
|
|
// Si plus de points disponibles, sortir
|
|
if (currentGrit <= 0 && currentLuck <= 0) {
|
|
break
|
|
}
|
|
|
|
const buttons = []
|
|
|
|
if (currentGrit > 0) {
|
|
buttons.push({
|
|
action: "grit",
|
|
type: "button",
|
|
label: `Spend 1 Grit (+1D6) [${currentGrit} left]`,
|
|
icon: "fa-solid fa-fist-raised",
|
|
callback: () => "grit"
|
|
})
|
|
}
|
|
|
|
if (currentLuck > 0) {
|
|
buttons.push({
|
|
action: "luck",
|
|
type: "button",
|
|
label: `Spend 1 Luck (+1D6) [${currentLuck} left]`,
|
|
icon: "fa-solid fa-clover",
|
|
callback: () => "luck"
|
|
})
|
|
}
|
|
|
|
buttons.push({
|
|
action: "continue",
|
|
type: "button",
|
|
label: "Continue (no bonus)",
|
|
icon: "fa-solid fa-forward",
|
|
callback: () => "continue"
|
|
})
|
|
|
|
const content = `
|
|
<div class="grit-luck-dialog">
|
|
<div class="combat-status">
|
|
<p><strong>${attackerName}</strong> rolled <strong>${attackRoll}</strong></p>
|
|
<p><strong>${defenderName}</strong> currently has <strong>${currentDefenseRoll + totalBonus}</strong></p>
|
|
${totalBonus > 0 ? `<p class="bonus-info">Bonus already added: +${totalBonus}</p>` : ''}
|
|
</div>
|
|
<p class="offer-text">You are losing! Spend Grit or Luck to add 1D6 to your defense?</p>
|
|
<p class="shield-warning"><i class="fa-solid fa-triangle-exclamation"></i> If you intend to use a shield, you must spend Grit or Luck <strong>first</strong> — the shield roll comes after.</p>
|
|
</div>
|
|
`
|
|
|
|
const choice = await foundry.applications.api.DialogV2.wait({
|
|
window: { title: "Defend with Grit or Luck" },
|
|
classes: ["lethalfantasy"],
|
|
content,
|
|
buttons,
|
|
rejectClose: false
|
|
})
|
|
|
|
if (!choice || choice === "continue") {
|
|
keepOffering = false
|
|
break
|
|
}
|
|
|
|
// Lancer 1D6
|
|
const bonusRoll = new Roll("1d6")
|
|
await bonusRoll.evaluate()
|
|
|
|
if (game?.dice3d) {
|
|
await game.dice3d.showForRoll(bonusRoll, game.user, true)
|
|
}
|
|
|
|
totalBonus += bonusRoll.total
|
|
|
|
// Déduire le point de Grit ou Luck
|
|
if (choice === "grit") {
|
|
await defender.update({ "system.grit.current": currentGrit - 1 })
|
|
await ChatMessage.create({
|
|
content: `<p><strong>${defenderName}</strong> spends 1 Grit and rolls <strong>${bonusRoll.total}</strong>! (Total defense bonus: +${totalBonus})</p>`,
|
|
speaker: ChatMessage.getSpeaker({ actor: defender })
|
|
})
|
|
} else if (choice === "luck") {
|
|
await defender.update({ "system.luck.current": currentLuck - 1 })
|
|
await ChatMessage.create({
|
|
content: `<p><strong>${defenderName}</strong> spends 1 Luck and rolls <strong>${bonusRoll.total}</strong>! (Total defense bonus: +${totalBonus})</p>`,
|
|
speaker: ChatMessage.getSpeaker({ actor: defender })
|
|
})
|
|
}
|
|
}
|
|
|
|
return totalBonus
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
static async offerAttackerGritBonus(attacker, currentAttackRoll, defenseRoll, attackerName, defenderName) {
|
|
let totalBonus = 0
|
|
let keepOffering = true
|
|
|
|
while (keepOffering && currentAttackRoll + totalBonus <= defenseRoll) {
|
|
const currentGrit = attacker.system.grit.current
|
|
|
|
// Si plus de points de Grit disponibles, sortir
|
|
if (currentGrit <= 0) {
|
|
break
|
|
}
|
|
|
|
const buttons = [
|
|
{
|
|
action: "grit",
|
|
type: "button",
|
|
label: `Spend 1 Grit (+1D6) [${currentGrit} left]`,
|
|
icon: "fa-solid fa-fist-raised",
|
|
callback: () => "grit"
|
|
},
|
|
{
|
|
action: "continue",
|
|
type: "button",
|
|
label: "Continue (no bonus)",
|
|
icon: "fa-solid fa-forward",
|
|
callback: () => "continue"
|
|
}
|
|
]
|
|
|
|
const content = `
|
|
<div class="grit-luck-dialog">
|
|
<div class="combat-status">
|
|
<p><strong>${attackerName}</strong> currently has <strong>${currentAttackRoll + totalBonus}</strong></p>
|
|
<p><strong>${defenderName}</strong> rolled <strong>${defenseRoll}</strong></p>
|
|
${totalBonus > 0 ? `<p class="bonus-info">Bonus already added: +${totalBonus}</p>` : ''}
|
|
</div>
|
|
<p class="offer-text">You are losing! Spend Grit to add 1D6 to your attack?</p>
|
|
</div>
|
|
`
|
|
|
|
const choice = await foundry.applications.api.DialogV2.wait({
|
|
window: { title: "Attack with Grit" },
|
|
classes: ["lethalfantasy"],
|
|
content,
|
|
buttons,
|
|
rejectClose: false
|
|
})
|
|
|
|
if (!choice || choice === "continue") {
|
|
keepOffering = false
|
|
break
|
|
}
|
|
|
|
// Lancer 1D6
|
|
const bonusRoll = new Roll("1d6")
|
|
await bonusRoll.evaluate()
|
|
|
|
if (game?.dice3d) {
|
|
await game.dice3d.showForRoll(bonusRoll, game.user, true)
|
|
}
|
|
|
|
totalBonus += bonusRoll.total
|
|
|
|
// Déduire le point de Grit
|
|
await attacker.update({ "system.grit.current": currentGrit - 1 })
|
|
await ChatMessage.create({
|
|
content: `<p><strong>${attackerName}</strong> spends 1 Grit and rolls <strong>${bonusRoll.total}</strong>! (Total attack bonus: +${totalBonus})</p>`,
|
|
speaker: ChatMessage.getSpeaker({ actor: attacker })
|
|
})
|
|
}
|
|
|
|
return totalBonus
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
static async compareAttackDefense(data) {
|
|
log("compareAttackDefense called with:", data)
|
|
|
|
// Compute D30 effects from the attack D30 message directly.
|
|
// This is more reliable than depending on the caller-provided values, which are
|
|
// computed per-client and may differ between clients due to cross-client processing order.
|
|
const d30DamageMultiplier = data.attackD30message?.type === "damage_multiplier"
|
|
? data.attackD30message.multiplier
|
|
: (data.d30DamageMultiplier || 1)
|
|
const d30Bleed = data.attackD30message?.type === "combo"
|
|
? (data.attackD30message.effects?.some(e => e.type === "bleed" || e.type === "internal_injury") ? "true" : "")
|
|
: data.attackD30message?.type === "bleed" ? "true" : (data.d30Bleed || "")
|
|
const d30DrMultiplier = data.d30DrMultiplier || 1
|
|
|
|
const outcome = data.outcome || (data.attackRoll > data.defenseRoll ? "hit" : "miss")
|
|
const isAttackWin = outcome !== "miss"
|
|
log("isAttackWin:", isAttackWin, "attackRoll:", data.attackRoll, "defenseRoll:", data.defenseRoll)
|
|
|
|
let damageButton = ""
|
|
if (isAttackWin && (data.attackWeaponId || data.attackRollKey)) {
|
|
log("Creating damage button. defenderId:", data.defenderId)
|
|
// Déterminer le type de dégâts à lancer
|
|
if (data.attackRollType === "weapon-attack") {
|
|
damageButton = `
|
|
<div class="attack-result-damage single-btn">
|
|
<button class="roll-damage-btn" data-attacker-id="${data.attackerId}" data-defender-id="${data.defenderId}" data-defender-token-id="${data.defenderTokenId || ""}" data-extra-shield-dr="${data.shieldDamageReduction || 0}" data-weapon-id="${data.attackWeaponId}" data-damage-type="medium" data-d30-bleed="${d30Bleed}" data-d30-damage-mult="${d30DamageMultiplier}" data-d30-dr-mult="${d30DrMultiplier}">
|
|
<i class="fa-solid fa-dice-d20"></i> Damage
|
|
</button>
|
|
</div>
|
|
`
|
|
} else if (data.attackRollType === "monster-attack") {
|
|
damageButton = `
|
|
<div class="attack-result-damage single-btn">
|
|
<button class="roll-damage-btn" data-attacker-id="${data.attackerId}" data-defender-id="${data.defenderId}" data-defender-token-id="${data.defenderTokenId || ""}" data-extra-shield-dr="${data.shieldDamageReduction || 0}" data-attack-key="${data.attackRollKey}" data-damage-type="monster" data-d30-bleed="${d30Bleed}" data-d30-damage-mult="${d30DamageMultiplier}" data-d30-dr-mult="${d30DrMultiplier}">
|
|
<i class="fa-solid fa-burst"></i> Damage
|
|
</button>
|
|
</div>
|
|
`
|
|
} else if (data.attackRollType === "spell-attack" || data.attackRollType === "miracle-attack") {
|
|
const attacker = game.actors.get(data.attackerId)
|
|
const spell = attacker?.items.get(data.attackWeaponId || data.attackRollKey)
|
|
const chosenTier = data.damageTier || "standard"
|
|
const allTiers = [
|
|
{ id: "standard", formula: spell?.system?.damageDice, label: "Standard" },
|
|
{ id: "overpowered", formula: spell?.system?.damageDiceOverpowered, label: "Overpowered" },
|
|
{ id: "overpowered2", formula: spell?.system?.damageDiceOverpowered2, label: "Overpowered 2" },
|
|
]
|
|
const tiers = allTiers.filter(t => t.id === chosenTier && t.formula)
|
|
if (tiers.length) {
|
|
const buttons = tiers.map(t => {
|
|
const escapedFormula = Handlebars.escapeExpression(t.formula)
|
|
return `
|
|
<button class="roll-damage-btn"
|
|
data-attacker-id="${data.attackerId}"
|
|
data-defender-id="${data.defenderId}"
|
|
data-defender-token-id="${data.defenderTokenId || ""}"
|
|
data-damage-type="spell"
|
|
data-damage-formula="${escapedFormula}"
|
|
data-d30-bleed="${d30Bleed}"
|
|
data-d30-damage-mult="${d30DamageMultiplier}"
|
|
data-d30-dr-mult="${d30DrMultiplier}">
|
|
<i class="fa-solid fa-wand-magic-sparkles"></i> ${t.label} (${escapedFormula})
|
|
</button>`
|
|
}).join("")
|
|
damageButton = `<div class="attack-result-damage spell-damage">${buttons}</div>`
|
|
}
|
|
}
|
|
}
|
|
|
|
const resultMessage = `
|
|
<div class="attack-result ${isAttackWin ? 'attack-success' : 'attack-failure'}">
|
|
<h3><i class="fa-solid ${isAttackWin ? 'fa-sword' : 'fa-shield'}"></i> Combat Result</h3>
|
|
<div class="combat-comparison">
|
|
<div class="combat-side attacker ${isAttackWin ? 'winner' : 'loser'}">
|
|
<div class="side-label">Attacker</div>
|
|
<div class="side-info">
|
|
<div class="side-name">${data.attackerName}</div>
|
|
<div class="side-roll">${data.attackRoll}</div>
|
|
</div>
|
|
</div>
|
|
<div class="combat-vs">VS</div>
|
|
<div class="combat-side defender ${isAttackWin ? 'loser' : 'winner'}">
|
|
<div class="side-label">Defender</div>
|
|
<div class="side-info">
|
|
<div class="side-name">${data.defenderName}</div>
|
|
<div class="side-roll">${data.defenseRoll}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="combat-result-text">
|
|
${outcome === "shielded-hit"
|
|
? `<i class="fa-solid fa-shield"></i> <strong>${data.defenderName}</strong> has blocked with shield — apply armor DR + shield DR <strong>${data.shieldDamageReduction || 0}</strong>.`
|
|
: isAttackWin
|
|
? `<i class="fa-solid fa-circle-check"></i> <strong>${data.attackerName}</strong> hits <strong>${data.defenderName}</strong>!`
|
|
: `<i class="fa-solid fa-shield-halved"></i> <strong>${data.defenderName}</strong> avoided the attack!`
|
|
}
|
|
</div>
|
|
${damageButton}
|
|
</div>
|
|
`
|
|
|
|
log("Creating combat result message...")
|
|
await ChatMessage.create({
|
|
content: resultMessage,
|
|
speaker: { alias: "Combat System" }
|
|
})
|
|
log("Combat result message created!")
|
|
}
|
|
|
|
static registerHandlebarsHelpers() {
|
|
|
|
Handlebars.registerHelper('isNull', function (val) {
|
|
return val == null;
|
|
});
|
|
Handlebars.registerHelper('match', function (val, search) {
|
|
if (val && search) {
|
|
return val?.match(search);
|
|
}
|
|
return false
|
|
});
|
|
|
|
Handlebars.registerHelper('exists', function (val) {
|
|
return val != null && val !== undefined;
|
|
});
|
|
|
|
Handlebars.registerHelper('isEmpty', function (list) {
|
|
if (list) return list.length === 0;
|
|
else return false;
|
|
});
|
|
|
|
Handlebars.registerHelper('notEmpty', function (list) {
|
|
return list.length > 0;
|
|
});
|
|
|
|
Handlebars.registerHelper('isNegativeOrNull', function (val) {
|
|
return val <= 0;
|
|
});
|
|
|
|
Handlebars.registerHelper('isNegative', function (val) {
|
|
return val < 0;
|
|
});
|
|
|
|
Handlebars.registerHelper('isPositive', function (val) {
|
|
return val > 0;
|
|
});
|
|
|
|
Handlebars.registerHelper('equals', function (val1, val2) {
|
|
return val1 === val2;
|
|
});
|
|
|
|
Handlebars.registerHelper('neq', function (val1, val2) {
|
|
return val1 !== val2;
|
|
});
|
|
|
|
Handlebars.registerHelper('gt', function (val1, val2) {
|
|
return val1 > val2;
|
|
})
|
|
|
|
Handlebars.registerHelper('lt', function (val1, val2) {
|
|
return val1 < val2;
|
|
})
|
|
|
|
Handlebars.registerHelper('gte', function (val1, val2) {
|
|
return val1 >= val2;
|
|
})
|
|
|
|
Handlebars.registerHelper('lte', function (val1, val2) {
|
|
return val1 <= val2;
|
|
})
|
|
Handlebars.registerHelper('and', function (val1, val2) {
|
|
return val1 && val2;
|
|
})
|
|
Handlebars.registerHelper('or', function (val1, val2) {
|
|
return val1 || val2;
|
|
})
|
|
|
|
Handlebars.registerHelper('or3', function (val1, val2, val3) {
|
|
return val1 || val2 || val3;
|
|
})
|
|
|
|
Handlebars.registerHelper('for', function (from, to, incr, block) {
|
|
let accum = '';
|
|
for (let i = from; i < to; i += incr)
|
|
accum += block.fn(i);
|
|
return accum;
|
|
})
|
|
|
|
Handlebars.registerHelper('not', function (cond) {
|
|
return !cond;
|
|
})
|
|
Handlebars.registerHelper('count', function (list) {
|
|
return list.length;
|
|
})
|
|
Handlebars.registerHelper('countKeys', function (obj) {
|
|
return Object.keys(obj).length;
|
|
})
|
|
|
|
Handlebars.registerHelper('isEnabled', function (configKey) {
|
|
return game.settings.get("bol", configKey);
|
|
})
|
|
Handlebars.registerHelper('split', function (str, separator, keep) {
|
|
return str.split(separator)[keep];
|
|
})
|
|
|
|
// If you need to add Handlebars helpers, here are a few useful examples:
|
|
Handlebars.registerHelper('concat', function () {
|
|
let outStr = '';
|
|
for (let arg in arguments) {
|
|
if (typeof arguments[arg] != 'object') {
|
|
outStr += arguments[arg];
|
|
}
|
|
}
|
|
return outStr;
|
|
})
|
|
|
|
Handlebars.registerHelper('add', function (a, b) {
|
|
return parseInt(a) + parseInt(b);
|
|
});
|
|
Handlebars.registerHelper('mul', function (a, b) {
|
|
return parseInt(a) * parseInt(b);
|
|
})
|
|
Handlebars.registerHelper('sub', function (a, b) {
|
|
return parseInt(a) - parseInt(b);
|
|
})
|
|
Handlebars.registerHelper('abbrev2', function (a) {
|
|
return a.substring(0, 2);
|
|
})
|
|
Handlebars.registerHelper('abbrev3', function (a) {
|
|
return a.substring(0, 3);
|
|
})
|
|
Handlebars.registerHelper('valueAtIndex', function (arr, idx) {
|
|
return arr[idx];
|
|
})
|
|
Handlebars.registerHelper('includesKey', function (items, type, key) {
|
|
return items.filter(i => i.type === type).map(i => i.system.key).includes(key);
|
|
})
|
|
Handlebars.registerHelper('includes', function (array, val) {
|
|
return array.includes(val);
|
|
})
|
|
Handlebars.registerHelper('eval', function (expr) {
|
|
return eval(expr);
|
|
})
|
|
Handlebars.registerHelper('isOwnerOrGM', function (actor) {
|
|
log("Testing actor", actor.isOwner, game.userId)
|
|
return actor.isOwner || game.isGM;
|
|
})
|
|
Handlebars.registerHelper('upperCase', function (text) {
|
|
if (typeof text !== 'string') return text
|
|
return text.toUpperCase()
|
|
})
|
|
Handlebars.registerHelper('upperFirst', function (text) {
|
|
if (typeof text !== 'string') return text
|
|
return text.charAt(0).toUpperCase() + text.slice(1)
|
|
})
|
|
Handlebars.registerHelper('upperFirstOnly', function (text) {
|
|
if (typeof text !== 'string') return text
|
|
return text.charAt(0).toUpperCase()
|
|
})
|
|
|
|
// Handle v12 removal of this helper
|
|
Handlebars.registerHelper('select', function (selected, options) {
|
|
const escapedValue = RegExp.escape(Handlebars.escapeExpression(selected));
|
|
const rgx = new RegExp(' value=[\"\']' + escapedValue + '[\"\']');
|
|
const html = options.fn(this);
|
|
return html.replace(rgx, "$& selected");
|
|
});
|
|
|
|
}
|
|
|
|
static getLethargyDice(level) {
|
|
for (let s of SYSTEM.SPELL_LETHARGY_DICE) {
|
|
if (Number(level) <= s.maxLevel) {
|
|
return s.dice
|
|
}
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
static async applyDamage(message, event) {
|
|
// Récupérer les données du message
|
|
let combatantId = event.currentTarget.dataset.combatantId
|
|
if (!combatantId) {
|
|
ui.notifications.error("No combatant selected")
|
|
return
|
|
}
|
|
|
|
// Try to find the target: first as a combat combatant, then as a scene token
|
|
let targetActor = null
|
|
if (game.combat) {
|
|
const combatant = game.combat.combatants.get(combatantId)
|
|
if (combatant) {
|
|
targetActor = combatant.token?.actor || game.actors.get(combatant.actorId)
|
|
}
|
|
}
|
|
if (!targetActor) {
|
|
// Fall back to scene token lookup (non-combat tokens use tokenId as their combatantId)
|
|
const token = canvas.tokens?.placeables?.find(t => t.id === combatantId)
|
|
targetActor = token?.actor
|
|
}
|
|
if (!targetActor) {
|
|
ui.notifications.error("Target actor not found")
|
|
return
|
|
}
|
|
|
|
// Récupérer les données de dégâts du message
|
|
// Use options.rollTotal (includes weapon modifier bonus) rather than roll.total (dice formula only)
|
|
let damageTotal = message.rolls[0]?.options?.rollTotal ?? message.rolls[0]?.total ?? 0
|
|
let weaponName = message.rolls[0]?.options?.rollName || "Unknown Weapon"
|
|
|
|
// Calculer les DR
|
|
let armorDR = targetActor.computeDamageReduction() || 0
|
|
let shieldDR = targetActor.getShieldDR() || 0
|
|
let totalDR = armorDR + shieldDR
|
|
|
|
// Créer le dialogue
|
|
const content = await foundry.applications.handlebars.renderTemplate(
|
|
"systems/fvtt-lethal-fantasy/templates/apply-damage-dialog.hbs",
|
|
{
|
|
targetName: targetActor.name,
|
|
weaponName: weaponName,
|
|
damageTotal: damageTotal,
|
|
armorDR: armorDR,
|
|
shieldDR: shieldDR,
|
|
totalDR: totalDR,
|
|
damageNoDR: damageTotal,
|
|
damageWithArmor: Math.max(0, damageTotal - armorDR),
|
|
damageWithAll: Math.max(0, damageTotal - totalDR)
|
|
}
|
|
)
|
|
|
|
const result = await foundry.applications.api.DialogV2.wait({
|
|
window: { title: "Apply Damage" },
|
|
classes: ["lethalfantasy"],
|
|
position: { width: 280 },
|
|
content,
|
|
buttons: [
|
|
{
|
|
action: "noDR",
|
|
type: "button",
|
|
label: "No DR",
|
|
callback: () => ({ drType: "none", damage: damageTotal })
|
|
},
|
|
{
|
|
action: "armorDR",
|
|
type: "button",
|
|
label: "With Armor DR",
|
|
callback: () => ({ drType: "armor", damage: Math.max(0, damageTotal - armorDR) })
|
|
},
|
|
{
|
|
action: "allDR",
|
|
type: "button",
|
|
label: "With Armor + Shield DR",
|
|
callback: () => ({ drType: "all", damage: Math.max(0, damageTotal - totalDR) })
|
|
},
|
|
{
|
|
action: "cancel",
|
|
type: "button",
|
|
label: "Cancel",
|
|
callback: () => null
|
|
}
|
|
],
|
|
rejectClose: false
|
|
})
|
|
|
|
if (result && result.damage !== undefined) {
|
|
await targetActor.applyDamage(-result.damage)
|
|
|
|
// Message de confirmation
|
|
let drText = ""
|
|
if (result.drType === "armor") {
|
|
drText = `Armor DR: ${armorDR}`
|
|
} else if (result.drType === "all") {
|
|
drText = `Total DR: ${totalDR}`
|
|
}
|
|
|
|
const messageContent = await foundry.applications.handlebars.renderTemplate(
|
|
"systems/fvtt-lethal-fantasy/templates/damage-applied-message.hbs",
|
|
{
|
|
targetName: targetActor.name,
|
|
damage: result.damage,
|
|
drText: drText,
|
|
weaponName: weaponName
|
|
}
|
|
)
|
|
|
|
await ChatMessage.create({
|
|
user: game.user.id,
|
|
speaker: { alias: targetActor.name },
|
|
mode: "gm",
|
|
content: messageContent
|
|
})
|
|
}
|
|
}
|
|
|
|
}
|