Files
fvtt-lethal-fantasy/module/utils/combat.mjs
T
uberwald 1ea1a4b4b7
Release Creation / build (release) Successful in 2m19s
FIx mulligan rolls
2026-07-01 10:38:19 +02:00

948 lines
37 KiB
JavaScript

import { SYSTEM } from "../config/system.mjs"
import { log } from "./helpers.mjs"
import { processD30BonusDice, hasD30Reroll } from "./d30.mjs"
export async function 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) {
showDefenseRequest(msg)
}
break
case "offerAttackerGrit":
// Vérifier si le message est destiné à cet utilisateur
if (msg.userId === game.user.id) {
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) {
handleAttackBoosted(msg)
}
break
}
}
export async function 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 offerAttackerGritBonus(
attacker,
attackRoll,
defenseRoll,
attackerName,
defenderName
)
const attackRollFinal = attackRoll + attackBonus
// Maintenant créer le message de comparaison
await compareAttackDefense({
attackerName,
attackerId,
attackRoll: attackRollFinal,
attackWeaponId,
attackRollType,
attackRollKey,
defenderName,
defenderId,
defenseRoll
})
}
export async function handleAttackBoosted(msg) {
const {
attackerName, attackerId, defenderName, defenderId, defenderTokenId,
attackerHandledBonus, attackRollFinal, defenseRoll, attackWeaponId, attackRollType, attackRollKey,
shieldDamageReduction: initialShieldDR,
d30Bleed, d30DamageMultiplier, d30DrMultiplier,
damageTier, attackD30message, defenseD30message, defenseRerollContext,
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 processD30BonusDice(defenseD30message, "defense", null, defender, true)
if (d30Result.modifier) {
updatedDefenseRoll += d30Result.modifier
if (d30Result.modifier > 0) {
const msg = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"d30Bonus", actorName:defenderName, value:d30Result.modifier, side:"defense"})
await ChatMessage.create({content: msg, speaker: ChatMessage.getSpeaker({actor: defender})})
}
}
if (d30Result.specialEffect === "flag") {
const msg = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"d30Flag", actorName:defenderName, specialName:d30Result.specialName || "Special Effect"})
await ChatMessage.create({content: msg, speaker: ChatMessage.getSpeaker({actor: defender})})
}
if (d30Result.specialEffect === "drMultiplier") {
defenseDrMultiplier = d30Result.multiplier
const msg = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"d30DRMultiplier", actorName:defenderName, value:d30Result.multiplier})
await ChatMessage.create({content: msg, speaker: ChatMessage.getSpeaker({actor: defender})})
}
}
// Show the defense reaction dialog — while-loop for multiple reactions
if (defender) {
while (updatedDefenseRoll < attackRollFinal) {
const shieldData = canShieldReact ? { label: shieldLabel, formula: shieldFormula, damageReduction: shieldDr } : null
const buttons = buildDefenseReactionButtons(defender, { canRerollDefense: hasD30Reroll(defenseD30message), shieldData, canShieldReact, canAdHocShield: canAdHoc })
const choice = await foundry.applications.api.DialogV2.wait({
window: { title: "Defense reactions — attack boosted" },
classes: ["lethalfantasy"],
content: await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/defense-reaction.hbs", {
attackerName,
attackRoll: attackRollFinal,
attackStatus: "boosted attack to",
defenderName,
defenseRoll: updatedDefenseRoll,
defenseStatus: "currently has",
d30message: defenseD30message || null,
offerText: "The attack was boosted! Choose how to improve the defense."
}),
buttons,
rejectClose: false
})
if (!choice || choice === "continue") break
if (choice === "grit") {
const bonusRoll = await rollBonusDie("1d6", defender)
updatedDefenseRoll += bonusRoll
await defender.update({ "system.grit.current": Math.max(0, (Number(defender.system?.grit?.current) || 0) - 1) })
const gritRmContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"grit", actorName:defenderName, resource:"Grit", value:bonusRoll, side:"defense"})
await ChatMessage.create({content: gritRmContent, speaker: ChatMessage.getSpeaker({actor: defender})})
} else if (choice === "luck") {
const bonusRoll = await rollBonusDie("1d6", defender)
updatedDefenseRoll += bonusRoll
await defender.update({ "system.luck.current": Math.max(0, (Number(defender.system?.luck?.current) || 0) - 1) })
const luckRmContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"luck", actorName:defenderName, resource:"Luck", value:bonusRoll, side:"defense"})
await ChatMessage.create({content: luckRmContent, speaker: ChatMessage.getSpeaker({actor: defender})})
} else if (choice === "bonusDie") {
const bonusDie = await promptCombatBonusDie(defenderName, "attack", updatedDefenseRoll, attackRollFinal)
if (bonusDie) {
const bonusRoll = await rollBonusDie(bonusDie, defender)
updatedDefenseRoll += bonusRoll
const bonusRmContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"bonusDie", actorName:defenderName, formula:bonusDie.toUpperCase(), value:bonusRoll, side:"defense"})
await ChatMessage.create({content: bonusRmContent, speaker: ChatMessage.getSpeaker({actor: defender})})
}
} else if (choice === "shieldReact" && canShieldReact) {
const shieldBonus = await rollBonusDie(shieldFormula, defender)
const newDefenseTotal = updatedDefenseRoll + shieldBonus
updatedDefenseRoll = newDefenseTotal
canShieldReact = false
if (newDefenseTotal >= attackRollFinal) {
shieldBlocked = true
shieldReaction = { damageReduction: shieldDr, label: shieldLabel, bonus: shieldBonus }
const shieldBlockContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"shieldBlock", actorName:defenderName, shieldLabel, shieldBonus, newTotal:newDefenseTotal, opposingRoll:attackRollFinal, shieldDR:shieldDr})
await ChatMessage.create({
content: shieldBlockContent,
speaker: ChatMessage.getSpeaker({ actor: defender })
})
} else {
const shieldFailContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"shieldFail", actorName:defenderName, shieldLabel, shieldBonus, newTotal:newDefenseTotal, opposingRoll:attackRollFinal})
await ChatMessage.create({
content: shieldFailContent,
speaker: ChatMessage.getSpeaker({ actor: defender })
})
}
} else if (choice === "rerollDefense" && defenseRerollContext) {
const oldDefenseRoll = updatedDefenseRoll
const reroll = await rerollConfiguredRoll(defenseRerollContext)
if (!reroll) continue
updatedDefenseRoll = reroll.options?.rollTotal || reroll.total || oldDefenseRoll
let newD30message = reroll.options?.D30message || null
const mulliganContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {
type: "mulligan",
actorName: defenderName,
side: "defense",
oldRoll: oldDefenseRoll,
newRoll: updatedDefenseRoll,
diceResults: reroll.options?.diceResults || [],
D30result: reroll.options?.D30result,
D30message: newD30message
})
await ChatMessage.create({content: mulliganContent, speaker: ChatMessage.getSpeaker({actor: defender})})
// Process new D30 bonus dice from the reroll
if (newD30message) {
defenseD30message = newD30message
const d30Result = await processD30BonusDice(defenseD30message, "defense", null, defender, true)
if (d30Result.modifier) {
updatedDefenseRoll += d30Result.modifier
if (d30Result.modifier > 0) {
const rmContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"d30Bonus", actorName:defenderName, value:d30Result.modifier, side:"defense"})
await ChatMessage.create({content: rmContent, speaker: ChatMessage.getSpeaker({actor: defender})})
}
}
if (d30Result.specialEffect === "flag") {
const rmContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"d30Flag", actorName:defenderName, specialName:d30Result.specialName || "Special Effect"})
await ChatMessage.create({content: rmContent, speaker: ChatMessage.getSpeaker({actor: defender})})
}
if (d30Result.specialEffect === "drMultiplier") {
const rmContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"d30DRMultiplier", actorName:defenderName, value:d30Result.multiplier})
await ChatMessage.create({content: rmContent, speaker: ChatMessage.getSpeaker({actor: defender})})
}
}
continue
} else if (choice === "adHocShield" && canAdHoc) {
const adHoc = await promptAdHocShield(defenderName, attackRollFinal, updatedDefenseRoll)
if (adHoc) {
const shieldBonus = await 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 }
const adHocShieldBlockContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"shieldBlock", actorName:defenderName, shieldLabel: `${adHoc.formula.toUpperCase()} shield`, shieldBonus, newTotal:newDefenseTotal, opposingRoll:attackRollFinal, shieldDR:adHoc.damageReduction})
await ChatMessage.create({
content: adHocShieldBlockContent,
speaker: ChatMessage.getSpeaker({ actor: defender })
})
} else {
const adHocShieldFailContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"shieldFail", actorName:defenderName, shieldLabel: `${adHoc.formula.toUpperCase()} shield`, shieldBonus, newTotal:newDefenseTotal, opposingRoll:attackRollFinal})
await ChatMessage.create({
content: adHocShieldFailContent,
speaker: ChatMessage.getSpeaker({ actor: defender })
})
}
}
}
}
}
const finalShieldDR = shieldBlocked ? (shieldReaction?.damageReduction || 0) : 0
const outcome = shieldBlocked ? "shielded-hit" : (updatedDefenseRoll >= attackRollFinal ? "miss" : "hit")
await 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
})
}
export async function 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"
const _storeNextDefenseData = (opts = {}) => {
game.lethalFantasy = game.lethalFantasy || {}
game.lethalFantasy.nextDefenseData = {
attackerId, attackRoll, attackerName, defenderName,
attackWeaponId, attackRollType, attackRollKey,
attackD30result, attackD30message, attackRerollContext,
damageTier: msg.damageTier,
defenderId: defender.id, defenderTokenId,
...(msg.attackNaturalRoll !== undefined && { attackNaturalRoll: msg.attackNaturalRoll }),
...(opts.isRanged !== undefined && { isRanged: opts.isRanged })
}
}
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 savesList = Object.values(savesConfig)
.filter(s => combatSaves.includes(s.id))
.map(s => ({id: s.id, label: game.i18n.localize(s.label)}))
const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/defense-request-save.hbs", {
attackerName, defenderName, weaponName, attackRoll, saves: savesList
})
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
_storeNextDefenseData()
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
const attacksList = enabledAttacks.map(([key, attack]) => ({key, name: attack.name}))
const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/defense-request-monster.hbs", {
attackerName, defenderName, weaponName, attackRoll, attacks: attacksList
})
// 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) {
_storeNextDefenseData({ 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) {
_storeNextDefenseData({ 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
const weaponsList = equippedWeapons.map(w => ({id: w.id, name: w.name}))
const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/defense-request-character.hbs", {
attackerName, defenderName, weaponName, attackRoll, weapons: weaponsList
})
// 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) {
_storeNextDefenseData({ isRanged: msg.isRanged })
log("Storing defense data for character:", defender.id)
await defender.prepareRoll("weapon-defense", result)
}
}
export function buildDefenseReactionButtons(defender, { canRerollDefense = false, shieldData = null, canShieldReact = false, canAdHocShield = false } = {}) {
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 (canRerollDefense) {
buttons.push({ action: "rerollDefense", type: "button", label: "Re-roll defense (Mulligan)", icon: "fa-solid fa-rotate-right", callback: () => "rerollDefense" })
}
if (canShieldReact && shieldData) {
buttons.push({ action: "shieldReact", type: "button", label: `Roll shield (${shieldData.label})`, icon: "fa-solid fa-shield", callback: () => "shieldReact" })
} else if (canAdHocShield) {
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" })
return buttons
}
export function getCombatBonusDiceChoices() {
return ["1d4", "1d6", "1d8", "1d10", "1d12", "1d20", "1d20e"]
}
export function 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
}
}
export async function promptCombatBonusDie(actorName, sideLabel, currentRoll, opposingRoll) {
const choicesList = getCombatBonusDiceChoices()
const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/bonus-die-select.hbs", {
actorName, currentRoll, opposingRoll, sideLabel, choices: choicesList
})
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 ?? choicesList[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>}
*/
export async function promptAdHocShield(defenderName, attackRoll, defenseRoll) {
const choicesList = getCombatBonusDiceChoices()
const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/ad-hoc-shield.hbs", {
defenderName, attackRoll, defenseRoll, choices: choicesList
})
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.
* @param {string} formula
* @param {Actor} actor
* @returns {Promise<number>}
*/
export async function rollBonusDie(formula, actor) {
const roll = new Roll(formula)
await roll.evaluate()
if (game?.dice3d) {
await game.dice3d.showForRoll(roll, game.user, true)
}
return roll.total
}
export async function 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
})
}
export async function offerAttackerGritBonus(attacker, currentAttackRoll, defenseRoll, attackerName, defenderName) {
let totalBonus = 0
let keepOffering = true
while (keepOffering && currentAttackRoll + totalBonus <= defenseRoll) {
const currentGrit = attacker.system.grit.current
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 = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/attack-grit.hbs", {
attackerName,
currentAttackRollWithBonus: currentAttackRoll + totalBonus,
defenderName,
defenseRoll,
totalBonus
})
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
}
const bonusRoll = new Roll("1d6")
await bonusRoll.evaluate()
if (game?.dice3d) {
await game.dice3d.showForRoll(bonusRoll, game.user, true)
}
totalBonus += bonusRoll.total
await attacker.update({ "system.grit.current": currentGrit - 1 })
const gritRm = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"grit", actorName:attackerName, resource:"Grit", value:bonusRoll.total, side:"attack"})
await ChatMessage.create({content: gritRm, speaker: ChatMessage.getSpeaker({actor: attacker})})
}
return totalBonus
}
export async function 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 shieldDamageReduction = data.shieldDamageReduction || 0
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 = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/damage-button.hbs", {
type: "weapon",
attackerId: data.attackerId,
defenderId: data.defenderId,
defenderTokenId: data.defenderTokenId || "",
shieldDamageReduction: shieldDamageReduction,
attackWeaponId: data.attackWeaponId,
d30Bleed,
d30DamageMultiplier,
d30DrMultiplier
})
} else if (data.attackRollType === "monster-attack") {
damageButton = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/damage-button.hbs", {
type: "monster",
attackerId: data.attackerId,
defenderId: data.defenderId,
defenderTokenId: data.defenderTokenId || "",
shieldDamageReduction: shieldDamageReduction,
attackRollKey: data.attackRollKey,
d30Bleed,
d30DamageMultiplier,
d30DrMultiplier
})
} 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 tierData = allTiers.filter(t => t.id === chosenTier && t.formula).map(t => ({
formula: Handlebars.escapeExpression(t.formula),
label: t.label
}))
if (tierData.length) {
damageButton = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/damage-button.hbs", {
type: "spell",
attackerId: data.attackerId,
defenderId: data.defenderId,
defenderTokenId: data.defenderTokenId || "",
tiers: tierData,
d30Bleed,
d30DamageMultiplier,
d30DrMultiplier
})
}
}
}
const resultMessage = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/combat-result.hbs", {
isAttackWin,
outcome,
attackerName: data.attackerName,
defenderName: data.defenderName,
attackRoll: data.attackRoll,
defenseRoll: data.defenseRoll,
shieldDamageReduction: shieldDamageReduction,
damageButton
})
log("Creating combat result message...")
await ChatMessage.create({
content: resultMessage,
speaker: { alias: "Combat System" }
})
log("Combat result message created!")
}
export async function 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
})
}
}