Files
uberwald 1ea1a4b4b7
Release Creation / build (release) Successful in 2m19s
FIx mulligan rolls
2026-07-01 10:38:19 +02:00

1100 lines
47 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { SYSTEM } from "../config/system.mjs"
import * as documents from "../documents/_module.mjs"
import LethalFantasyUtils, { log } from "../utils.mjs"
Hooks.on("renderChatMessageHTML", (message, html, data) => {
const typeMessage = data.message.flags.lethalFantasy?.typeMessage
// Message de demande de jet de dés
if (typeMessage === "askRoll") {
// Affichage des boutons de jet de dés uniquement pour les joueurs
if (game.user.isGM) {
for (const btn of html.querySelectorAll(".ask-roll-dice")) {
btn.style.display = "none"
}
} else {
for (const btn of html.querySelectorAll(".ask-roll-dice")) {
btn.addEventListener("click", () => {
const type = btn.dataset.rollType
const value = btn.dataset.rollTarget
const avantage = btn.dataset.rollAvantage ?? "normal"
const character = game.user.character
if (type === "resource") character.rollResource(value)
else if (type === "save") character.rollSave(value, avantage)
})
}
}
}
// Gestion du survol et du clic sur les boutons de dégâts pour les GMs
if (game.user.isGM) {
// Show damage buttons only for GM
for (const btn of html.querySelectorAll(".li-apply-wounds")) {
btn.style.display = "block"
}
for (const btn of html.querySelectorAll(".apply-wounds-btn")) {
btn.addEventListener("mouseenter", () => {
const combatantId = btn.dataset.combatantId
if (combatantId && game.combat) {
const combatant = game.combat.combatants.get(combatantId)
if (combatant?.token) {
const token = canvas.tokens.get(combatant.token.id)
if (token) {
token.control({ releaseOthers: true })
canvas.animatePan(token.center)
}
}
}
})
btn.addEventListener("mouseleave", () => canvas.tokens.releaseAll())
btn.addEventListener("click", event => LethalFantasyUtils.applyDamage(message, event))
}
}
// Gestion du survol et du clic sur les boutons de défense
for (const btn of html.querySelectorAll(".request-defense-btn")) {
btn.addEventListener("mouseenter", () => {
const tokenId = btn.dataset.tokenId
if (tokenId) {
const token = canvas.tokens.get(tokenId)
if (token) {
token.control({ releaseOthers: true })
canvas.animatePan(token.center)
}
}
})
btn.addEventListener("mouseleave", () => canvas.tokens.releaseAll())
// Gestionnaire pour les boutons de demande de défense
btn.addEventListener("click", event => {
event.preventDefault()
event.stopPropagation()
const combatantId = btn.dataset.combatantId
const tokenId = btn.dataset.tokenId
// Récupérer le combattant soit du combat, soit directement du token
let combatant = null
let token = null
if (game.combat && combatantId) {
combatant = game.combat.combatants.get(combatantId)
}
// Si pas de combattant trouvé, chercher le token directement
if (!combatant && tokenId) {
token = canvas.tokens.get(tokenId)
if (token) {
// Créer un pseudo-combattant avec les infos du token
combatant = {
actor: token.actor,
name: token.name,
token: token,
actorId: token.actorId
}
}
}
if (!combatant) return
// Récupérer les informations de l'attaquant depuis le message
const attackerName = message.rolls[0]?.actorName || "Unknown"
const attackerId = message.rolls[0]?.actorId
const weaponName = message.rolls[0]?.rollName || "weapon"
const attackRoll = message.rolls[0]?.rollTotal || 0
const defenderName = combatant.name
const attackRollType = message.rolls[0]?.type
const rollTargetData = message.rolls[0]?.rollTarget
// For spell/miracle attacks, rollTarget IS the spell item (serialised as its data object).
// For weapon attacks, rollTarget is a plain skill+weapon object and weapon.id is the weapon.
const isSpellMiracleAttack = attackRollType === "spell-attack" || attackRollType === "miracle-attack"
const attackWeaponId = isSpellMiracleAttack
? (rollTargetData?._id || rollTargetData?.id)
: (rollTargetData?.weapon?.id || rollTargetData?.weapon?._id)
const attackRollKey = rollTargetData?.rollKey
log(`[LF] request-defense-btn | attackRollType=${attackRollType} defender=${defenderName} defenderType=${combatant.actor?.type}`)
const attackD30result = message.rolls[0]?.options?.D30result || null
const attackD30message = message.rolls[0]?.options?.D30message || null
const attackDiceResults = message.rolls[0]?.options?.diceResults || null
const attackNaturalRoll = attackDiceResults?.[0]?.value || null
const damageTier = message.rolls[0]?.options?.damageTier || "standard"
const attackRerollContext = {
rollType: message.rolls[0]?.options?.rollType,
rollTarget: foundry.utils.duplicate(message.rolls[0]?.options?.rollTarget ?? {}),
actorId: message.rolls[0]?.options?.actorId,
actorName: message.rolls[0]?.options?.actorName,
actorImage: message.rolls[0]?.options?.actorImage,
defenderId: combatant.actor?.id || null,
defenderTokenId: tokenId || combatant.token?.id || null,
rollContext: foundry.utils.duplicate(message.rolls[0]?.options?.rollData ?? {})
}
// Préparer le message de demande de défense
// isRanged: true si le monstre était en mode ranged (via rollTarget.attackMode stocké dans le roll)
// OU si l'attaquant utilisait une arme ranged (weapon-attack avec weaponType === "ranged")
const attacker = game.actors.get(attackerId)
const rollTargetOptions = message.rolls[0]?.options?.rollTarget
const attackerWeapon = rollTargetOptions?.weapon
const isRangedAttack = (rollTargetOptions?.attackMode === "ranged")
|| (attacker?.type === "monster" && attacker.system.attackMode === "ranged")
|| (attackerWeapon?.system?.weaponType === "ranged")
|| (rollTargetOptions?.isRangedAttack === true)
const defenseMsg = {
type: "requestDefense",
attackerName,
attackerId,
defenderName,
weaponName,
attackRoll,
attackWeaponId,
attackRollType,
attackRollKey,
attackD30result,
attackD30message,
attackRerollContext,
attackNaturalRoll,
damageTier,
combatantId,
tokenId,
isRanged: isRangedAttack
}
// Envoyer le message socket à l'utilisateur contrôlant le combatant
// Only consider active (online) users; fall back to any active GM for unowned/GM monsters.
let owners = game.users.filter(u => u.active && combatant.actor.testUserPermission(u, "OWNER"))
if (owners.length === 0) {
owners = game.users.filter(u => u.active && u.isGM)
}
// Récupérer l'acteur attaquant pour vérifier qui l'a lancé
const attackerOwners = attacker ? game.users.filter(u => attacker.testUserPermission(u, "OWNER")).map(u => u.id) : []
// Monsters always need their owner (usually the GM) to roll a save/defense,
// even if that owner also controls the attacker. Only skip for same-player PC-vs-PC.
const defenderIsMonster = combatant.actor?.type === "monster"
let messageSent = false
owners.forEach(owner => {
// Don't let a player be both attacker and defender for their own PC, unless defending a monster.
if (attackerOwners.includes(owner.id) && owner.id === game.user.id && !defenderIsMonster) {
// Ne rien faire - on ne veut pas que l'attaquant se défende contre lui-même
return
}
if (owner.id === game.user.id) {
// Si l'utilisateur actuel est le propriétaire du défenseur (mais pas l'attaquant), appeler directement
LethalFantasyUtils.showDefenseRequest({ ...defenseMsg, userId: owner.id })
messageSent = true
} else {
// Sinon, envoyer via socket
game.socket.emit(`system.${SYSTEM.id}`, { ...defenseMsg, userId: owner.id })
messageSent = true
}
})
// Notification pour l'attaquant
if (messageSent) {
ui.notifications.info(`Defense request sent to ${defenderName}'s controller`)
}
})
}
// Gestionnaire pour les boutons de jet de dégâts (armes et résultats de combat)
for (const btn of html.querySelectorAll(".damage-roll-btn, .roll-damage-btn")) {
btn.addEventListener("click", async event => {
event.preventDefault()
event.stopPropagation()
const weaponId = btn.dataset.weaponId
const attackKey = btn.dataset.attackKey
const attackerId = btn.dataset.attackerId
const defenderId = btn.dataset.defenderId
const defenderTokenId = btn.dataset.defenderTokenId || null
const extraShieldDr = Number(btn.dataset.extraShieldDr || 0)
const damageType = btn.dataset.damageType
const damageFormula = btn.dataset.damageFormula
const damageModifier = btn.dataset.damageModifier
const isMonster = btn.dataset.isMonster
const d30Bleed = btn.dataset.d30Bleed === "true"
const d30DamageMultiplier = Number(btn.dataset.d30DamageMult) || 1
const d30DrMultiplier = Number(btn.dataset.d30DrMult) || 1
// Récupérer l'acteur (soit depuis le message, soit depuis attackerId)
const actor = attackerId ? game.actors.get(attackerId) : game.actors.get(message.rolls[0]?.actorId)
if (!actor) {
ui.notifications.error("Actor not found")
return
}
// Pour les sorts, rouler les dés de dégâts avec DR manuelle optionnelle
if (damageType === "spell" && damageFormula) {
const manualDR = await foundry.applications.api.DialogV2.wait({
window: { title: game.i18n.localize("LETHALFANTASY.Combat.spellDRDialogTitle") },
classes: ["lethalfantasy"],
position: { width: 320 },
content: await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/spell-dr.hbs", {
msg: game.i18n.localize("LETHALFANTASY.Combat.spellDRDialogMsg"),
label: game.i18n.localize("LETHALFANTASY.Combat.spellDRLabel")
}),
buttons: [
{
action: "noDR",
label: game.i18n.localize("LETHALFANTASY.Combat.spellNoDR"),
icon: "fa-solid fa-wand-magic-sparkles",
callback: () => 0
},
{
action: "applyDR",
label: game.i18n.localize("LETHALFANTASY.Combat.spellApplyDR"),
icon: "fa-solid fa-shield",
callback: (event, button) => Number(button.form?.elements?.manualDr?.value) || 0
},
{
action: "cancel",
label: game.i18n.localize("LETHALFANTASY.Combat.proceedNo"),
callback: () => "cancel"
}
],
rejectClose: false
})
if (manualDR === null || manualDR === "cancel") return
const rollOpts = {
type: "spell-damage",
rollType: "spell-damage",
rollName: damageFormula,
isDamage: true,
rollData: { isDamage: true },
manualDR: manualDR,
defenderId,
defenderTokenId,
actorId: actor.id,
actorName: actor.name,
actorImage: actor.img,
d30Bleed,
d30DamageMultiplier,
d30DrMultiplier
}
await documents.LethalFantasyRoll.rollSpellDamageToMessage(damageFormula, rollOpts)
return
}
// Pour les boutons de résultat de combat (monster damage)
if (damageType === "monster" && attackKey) {
await actor.system.prepareMonsterRoll("monster-damage", attackKey, undefined, undefined, undefined, defenderId, defenderTokenId, extraShieldDr, { d30Bleed, d30DamageMultiplier, d30DrMultiplier })
return
}
// Pour les monstres, utiliser prepareMonsterRoll
if (isMonster === "true" || actor.type === "monster") {
await actor.system.prepareMonsterRoll("monster-damage", weaponId, undefined, undefined, damageModifier, defenderId, defenderTokenId, extraShieldDr, { d30Bleed, d30DamageMultiplier, d30DrMultiplier })
return
}
// Pour les personnages, récupérer l'arme
const weapon = actor.items.get(weaponId)
if (!weapon) {
ui.notifications.error("Weapon not found")
return
}
// Lancer les dégâts
const rollType = "weapon-damage"
await actor.prepareRoll(rollType, weaponId, undefined, defenderId, defenderTokenId, extraShieldDr, { d30Bleed, d30DamageMultiplier, d30DrMultiplier })
})
}
// Masquer les boutons de dommages dans les messages de résultat de combat si l'utilisateur n'est pas l'attaquant
for (const btn of html.querySelectorAll(".roll-damage-btn")) {
const attackerId = btn.dataset.attackerId
if (attackerId) {
const attacker = game.actors.get(attackerId)
// Masquer le bouton si l'utilisateur n'est pas GM et ne possède pas l'attaquant
if (!game.user.isGM && !attacker?.testUserPermission(game.user, "OWNER")) {
btn.style.display = "none"
}
}
}
})
// Hook pour ajouter les données d'attaque au message de défense
Hooks.on("preCreateChatMessage", (message) => {
const rollType = message.rolls[0]?.options?.rollType
// Si c'est un message de défense et qu'on a des données en attente
if ((rollType === "weapon-defense" || rollType === "monster-defense" || rollType === "save") && game.lethalFantasy?.nextDefenseData) {
// Ajouter les données dans les flags du message
message.updateSource({
[`flags.${SYSTEM.id}.attackData`]: game.lethalFantasy.nextDefenseData
})
log("Added attack data to defense message:", game.lethalFantasy.nextDefenseData)
// Nettoyer
delete game.lethalFantasy.nextDefenseData
}
})
// Hook global pour gérer l'offre de Grit à l'attaquant après une défense
Hooks.on("createChatMessage", async (message) => {
const rollType = message.rolls[0]?.options?.rollType
log("Defense hook checking message, rollType:", rollType)
// Vérifier si c'est un message de défense
if (rollType !== "weapon-defense" && rollType !== "monster-defense" && rollType !== "save") return
// Récupérer les données d'attaque depuis les flags
const attackData = message.flags?.[SYSTEM.id]?.attackData
log("Defense message confirmed, attackData:", attackData)
if (!attackData) {
log("No attack data found in message flags")
return
}
const {
attackerId,
attackRoll,
attackerName,
defenderName,
attackWeaponId,
attackRollType,
attackRollKey,
attackRerollContext,
attackNaturalRoll,
damageTier,
defenderId,
defenderTokenId
} = attackData
let { attackD30message } = attackData
let defenseRoll = message.rolls[0]?.options?.rollTotal || message.rolls[0]?.total || 0
let defenseD30message = message.rolls[0]?.options?.D30message || null
log("Processing defense:", { attackRoll, defenseRoll, attackerId, defenderId })
// Attendre l'animation 3D
if (game?.dice3d) {
await game.dice3d.waitFor3DAnimationByMessageID(message.id)
}
// Récupérer le défenseur et l'attaquant
const defender = game.actors.get(defenderId)
const attacker = game.actors.get(attackerId)
const defenseRerollContext = {
rollType: message.rolls[0]?.options?.rollType,
rollTarget: foundry.utils.duplicate(message.rolls[0]?.options?.rollTarget ?? {}),
actorId: message.rolls[0]?.options?.actorId,
actorName: message.rolls[0]?.options?.actorName,
actorImage: message.rolls[0]?.options?.actorImage,
defenderId,
defenderTokenId,
rollContext: foundry.utils.duplicate(message.rolls[0]?.options?.rollData ?? {})
}
const isPrimaryController = actor => {
if (!actor) return false
const activePlayerOwners = game.users.filter(u => u.active && !u.isGM && actor.testUserPermission(u, "OWNER"))
if (activePlayerOwners.length > 0) {
return activePlayerOwners[0].id === game.user.id
}
return game.user.isGM
}
const createReactionMessage = async (actorDocument, data) => {
const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", data)
await ChatMessage.create({
content,
speaker: ChatMessage.getSpeaker({ actor: actorDocument })
})
}
// Detect cross-client scenario: attacker has an active non-GM owner on another client
const attackerHasNonGMOwner = attacker && game.users.some(u => u.active && !u.isGM && attacker.testUserPermission(u, "OWNER"))
const attackerIsCrossClient = attackerHasNonGMOwner && !isPrimaryController(attacker)
// When a GM-owned attacker has D30 data, the D30 bonus is only applied on the GM's
// client. The defender must wait for the 'attackBoosted' socket to get the updated
// attack roll instead of processing the original unboosted value via the hook.
const d30PendingFromGM = attackD30message && attacker && !attackerHasNonGMOwner && !isPrimaryController(attacker)
// Mirror side: on the GM's client, this socket MUST always be sent when a GM-owned
// attacker has D30 data, even if the bonus was 0, because the defender can't know
// the outcome without it.
const d30RequiresSocket = attackD30message && attacker && !attackerHasNonGMOwner && isPrimaryController(attacker)
// Reaction phase — both sides may use grit/luck/shield/mulligan before the outcome is resolved.
// After a mulligan reroll (either side), the comparison restarts so both sides can react to the new numbers.
let defenderHandledBonus = false
let attackerHandledBonus = false
let shieldReaction = null
let shieldBlocked = false
const isSpellOrMiracle = attackRollType === "spell-attack" || attackRollType === "miracle-attack"
// These persist across mulligan restarts (once used they stay consumed)
const shieldData = LethalFantasyUtils.getShieldReactionData(defender)
let canRerollDefense = LethalFantasyUtils.hasD30Reroll(defenseD30message)
let canShieldReact = !!shieldData
let canAdHocShield = !shieldData
let attackRollFinal = attackRoll
let canRerollAttack = LethalFantasyUtils.hasD30Reroll(attackD30message)
let mulliganRestart = false
// These persist across mulligan restarts (D30 bonus only applied once)
let defenseD30Processed = false
let attackD30Processed = false
// D30 changed the attack in a way that needs cross-client sync
let d30ChangedAttack = false
// D30 combat effects for damage application
let d30Bleed = false
let d30DamageMultiplier = 1
let d30DrMultiplier = 1
do {
mulliganRestart = false
defenderHandledBonus = false
attackerHandledBonus = false
d30ChangedAttack = false
// ── D30 bonus dice (defense) — resolved before grit/luck/shield ───────
if (defenseD30message && !defenseD30Processed && isPrimaryController(defender) && !attackerIsCrossClient) {
const d30Result = await LethalFantasyUtils.processD30BonusDice(defenseD30message, "defense", null, defender, true)
if (d30Result.modifier) {
defenseRoll += d30Result.modifier
if (d30Result.modifier > 0) {
await createReactionMessage(defender, {type:"d30Bonus", actorName:defenderName, value:d30Result.modifier, side:"defense"})
}
}
if (d30Result.specialEffect === "flag") {
await createReactionMessage(defender, {type:"d30Flag", actorName:defenderName, specialName:d30Result.specialName||"Special Effect"})
}
if (d30Result.specialEffect === "drMultiplier") {
d30DrMultiplier = d30Result.multiplier
await createReactionMessage(defender, {type:"d30DRMultiplier", actorName:defenderName, value:d30Result.multiplier})
}
defenseD30Processed = true
}
// ── Defense reaction loop ──────────────────────────────────────────────
// Skip when attacker is cross-client, or when D30 bonus is pending from GM —
// the socket handler (handleAttackBoosted) will show the defense dialog and
// create the comparison message with the updated attack roll.
if (defender && defenseRoll < attackRollFinal && isPrimaryController(defender) && !isSpellOrMiracle && !attackerIsCrossClient && !d30PendingFromGM) {
while (defenseRoll < attackRollFinal) {
const buttons = LethalFantasyUtils.buildDefenseReactionButtons(defender, { canRerollDefense, shieldData, canShieldReact, canAdHocShield })
const dialogContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/defense-reaction.hbs", {
attackerName,
attackStatus: "rolled",
attackRoll: attackRollFinal,
defenderName,
defenseStatus: "currently has",
defenseRoll,
d30message: defenseD30message || null,
offerText: "Choose how to improve the defense before resolving the hit."
})
const choice = await foundry.applications.api.DialogV2.wait({
window: { title: "Defense reactions" },
classes: ["lethalfantasy"],
content: dialogContent,
buttons,
rejectClose: false
})
if (!choice || choice === "continue") break
defenderHandledBonus = true
if (choice === "grit") {
const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender)
defenseRoll += bonusRoll
await defender.update({ "system.grit.current": Math.max(0, (Number(defender.system?.grit?.current) || 0) - 1) })
await createReactionMessage(defender, {type:"grit", actorName:defenderName, resource:"Grit", value:bonusRoll, side:"defense"})
continue
}
if (choice === "luck") {
const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender)
defenseRoll += bonusRoll
await defender.update({ "system.luck.current": Math.max(0, (Number(defender.system?.luck?.current) || 0) - 1) })
await createReactionMessage(defender, {type:"luck", actorName:defenderName, resource:"Luck", value:bonusRoll, side:"defense"})
continue
}
if (choice === "bonusDie") {
const bonusDie = await LethalFantasyUtils.promptCombatBonusDie(defenderName, "attack", defenseRoll, attackRollFinal)
if (!bonusDie) continue
const bonusRoll = await LethalFantasyUtils.rollBonusDie(bonusDie, defender)
defenseRoll += bonusRoll
await createReactionMessage(defender, {type:"bonusDie", actorName:defenderName, formula:bonusDie.toUpperCase(), value:bonusRoll, side:"defense"})
continue
}
if (choice === "rerollDefense" && canRerollDefense) {
const oldDefenseRoll = defenseRoll
const reroll = await LethalFantasyUtils.rerollConfiguredRoll(defenseRerollContext)
canRerollDefense = false
if (!reroll) continue
defenseRoll = reroll.options?.rollTotal || reroll.total || oldDefenseRoll
await createReactionMessage(defender, {
type: "mulligan",
actorName: defenderName,
side: "defense",
oldRoll: oldDefenseRoll,
newRoll: defenseRoll,
diceResults: reroll.options?.diceResults || [],
D30result: reroll.options?.D30result,
D30message: reroll.options?.D30message
})
// Apply new D30 result on the restart
if (reroll.options?.D30message) {
defenseD30message = reroll.options.D30message
defenseD30Processed = false
}
// Restart the full comparison so both sides can react to the new roll
mulliganRestart = true
break
}
if (choice === "shieldReact" && canShieldReact) {
const shieldBonus = await LethalFantasyUtils.rollBonusDie(shieldData.formula, defender)
const newDefenseTotal = defenseRoll + shieldBonus
defenseRoll = newDefenseTotal
canShieldReact = false
if (newDefenseTotal >= attackRollFinal) {
shieldBlocked = true
shieldReaction = {
damageReduction: shieldData.damageReduction,
label: shieldData.label,
bonus: shieldBonus
}
await createReactionMessage(defender, {type:"shieldBlock", actorName:defenderName, shieldLabel:shieldData.label, shieldBonus, newTotal:newDefenseTotal, opposingRoll:attackRollFinal, shieldDR:shieldData.damageReduction})
} else {
shieldReaction = null
await createReactionMessage(defender, {type:"shieldFail", actorName:defenderName, shieldLabel:shieldData.label, shieldBonus, newTotal:newDefenseTotal, opposingRoll:attackRollFinal})
}
}
if (choice === "adHocShield") {
const adHoc = await LethalFantasyUtils.promptAdHocShield(defenderName, attackRollFinal, defenseRoll)
if (!adHoc) continue
const shieldBonus = await LethalFantasyUtils.rollBonusDie(adHoc.formula, defender)
const newDefenseTotal = defenseRoll + shieldBonus
defenseRoll = newDefenseTotal
canShieldReact = false
canAdHocShield = false
if (newDefenseTotal >= attackRollFinal) {
shieldBlocked = true
shieldReaction = {
damageReduction: adHoc.damageReduction,
label: `${adHoc.formula.toUpperCase()} shield`,
bonus: shieldBonus
}
await createReactionMessage(defender, {type:"shieldBlock", actorName:defenderName, shieldLabel:`${adHoc.formula.toUpperCase()} shield`, shieldBonus, newTotal:newDefenseTotal, opposingRoll:attackRollFinal, shieldDR:adHoc.damageReduction})
} else {
shieldReaction = null
await createReactionMessage(defender, {type:"shieldFail", actorName:defenderName, shieldLabel:`${adHoc.formula.toUpperCase()} shield`, shieldBonus, newTotal:newDefenseTotal, opposingRoll:attackRollFinal})
}
}
}
}
if (mulliganRestart) continue
// ── D30 bonus dice (attack) — resolved before grit/luck ────────────────
if (attackD30message && !attackD30Processed) {
const preD30AttackRoll = attackRollFinal
const canDialog = isPrimaryController(attacker)
const d30Result = await LethalFantasyUtils.processD30BonusDice(attackD30message, "attack", attackNaturalRoll, attacker, canDialog)
if (d30Result.modifier) {
attackRollFinal += d30Result.modifier
if (d30Result.modifier > 0 && canDialog) {
await createReactionMessage(attacker, {type:"d30Bonus", actorName:attackerName, value:d30Result.modifier, side:"attack"})
}
}
if (d30Result.specialEffect === "flag" && canDialog) {
await createReactionMessage(attacker, {type:"d30Flag", actorName:attackerName, specialName:d30Result.specialName||"Special Effect"})
}
if (d30Result.specialEffect === "bleed") {
d30Bleed = true
if (canDialog) {
await createReactionMessage(attacker, {type:"d30Bleed", actorName:attackerName})
}
}
if (d30Result.specialEffect === "damageMultiplier") {
d30DamageMultiplier = d30Result.multiplier
if (canDialog) {
await createReactionMessage(attacker, {type:"d30DamageMultiplier", actorName:attackerName, value:d30Result.multiplier})
}
}
attackD30Processed = true
// Track whether D30 actually changed the attack value (for cross-client sync)
d30ChangedAttack = (attackRollFinal !== preD30AttackRoll)
// If D30 boosted attack past defense, restart so defender can react.
// Only restart when D30 actually changed the outcome (pre-D30 defender was
// winning or tied, post-D30 defender is losing).
if (defender && preD30AttackRoll <= defenseRoll && defenseRoll < attackRollFinal) {
mulliganRestart = true
continue
}
}
// ── Attack reaction loop ───────────────────────────────────────────────
if (attacker && attackRollFinal <= defenseRoll && isPrimaryController(attacker)) {
while (attackRollFinal <= defenseRoll) {
const currentGrit = Number(attacker.system?.grit?.current) || 0
const buttons = []
if (currentGrit > 0) {
buttons.push({
action: "grit",
label: `Spend 1 Grit (+1D6) [${currentGrit} left]`,
icon: "fa-solid fa-fist-raised",
callback: () => "grit"
})
}
buttons.push({
action: "bonusDie",
label: "Add bonus die",
icon: "fa-solid fa-dice",
callback: () => "bonusDie"
})
if (canRerollAttack && attackRerollContext) {
buttons.push({
action: "rerollAttack",
label: "Re-roll attack (Mulligan)",
icon: "fa-solid fa-rotate-right",
callback: () => "rerollAttack"
})
}
buttons.push({
action: "continue",
label: "Continue (no attack bonus)",
icon: "fa-solid fa-forward",
callback: () => "continue"
})
const dialogContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/attack-reaction.hbs", {
attackerName,
attackRoll: attackRollFinal,
defenderName,
defenseRoll,
d30message: attackD30message || null,
offerText: "Choose how to improve the attack before resolving the combat result."
})
const choice = await foundry.applications.api.DialogV2.wait({
window: { title: "Attack reactions" },
classes: ["lethalfantasy"],
content: dialogContent,
buttons,
rejectClose: false
})
if (!choice || choice === "continue") break
attackerHandledBonus = true
if (choice === "grit") {
const attackBonus = await LethalFantasyUtils.rollBonusDie("1d6", attacker)
attackRollFinal += attackBonus
await attacker.update({ "system.grit.current": currentGrit - 1 })
await createReactionMessage(attacker, {type:"grit", actorName:attackerName, resource:"Grit", value:attackBonus, side:"attack"})
continue
}
if (choice === "bonusDie") {
const bonusDie = await LethalFantasyUtils.promptCombatBonusDie(attackerName, "defense", attackRollFinal, defenseRoll)
if (!bonusDie) continue
const attackBonus = await LethalFantasyUtils.rollBonusDie(bonusDie, attacker)
attackRollFinal += attackBonus
await createReactionMessage(attacker, {type:"bonusDie", actorName:attackerName, formula:bonusDie.toUpperCase(), value:attackBonus, side:"attack"})
continue
}
if (choice === "rerollAttack" && canRerollAttack && attackRerollContext) {
const oldAttackRoll = attackRollFinal
const reroll = await LethalFantasyUtils.rerollConfiguredRoll(attackRerollContext)
canRerollAttack = false
if (!reroll) continue
attackRollFinal = reroll.options?.rollTotal || reroll.total || oldAttackRoll
await createReactionMessage(attacker, {
type: "mulligan",
actorName: attackerName,
side: "attack",
oldRoll: oldAttackRoll,
newRoll: attackRollFinal,
diceResults: reroll.options?.diceResults || [],
D30result: reroll.options?.D30result,
D30message: reroll.options?.D30message
})
// Apply new D30 result on the restart
if (reroll.options?.D30message) {
attackD30message = reroll.options.D30message
attackD30Processed = false
}
// Restart the full comparison so both sides can react to the new roll
mulliganRestart = true
break
}
}
}
// Cross-client coordination: only delegate to the defender's client
// when the attacker boosted past the defense. When no attacker boost
// occurred, the defender's client already processed the defense via
// the createChatMessage hook and will create the correct comparison.
// Sending attackBoosted with stale (unboosted) values would cause
// the defender to see a duplicate dialog and overwrite the result.
if (defender && isPrimaryController(attacker)) {
const defenderOwner = game.users.find(u => u.active && !u.isGM && defender.testUserPermission(u, "OWNER"))
|| game.users.find(u => u.active && u.isGM)
if (defenderOwner && defenderOwner.id !== game.user.id) {
// Send attackBoosted when the attacker actually boosted (so defender
// can respond to the new numbers), OR when the attacker has an active
// non-GM owner (PC-vs-PC cross-client) — the defender's hook-based
// processing is suppressed by attackerIsCrossClient, so the socket
// handler must show the defense dialog instead.
if (attackerHandledBonus || attackerHasNonGMOwner || d30RequiresSocket) {
const sData = LethalFantasyUtils.getShieldReactionData(defender)
game.socket.emit(`system.${SYSTEM.id}`, {
type: "attackBoosted",
userId: defenderOwner.id,
attackerName, attackerId, defenderName, defenderId, defenderTokenId,
attackerHandledBonus, attackRollFinal, defenseRoll, attackWeaponId, attackRollType, attackRollKey,
shieldDamageReduction: shieldBlocked ? shieldReaction?.damageReduction ?? 0 : 0,
d30Bleed: d30Bleed ? "true" : "",
d30DamageMultiplier, d30DrMultiplier,
damageTier: damageTier || "standard",
attackD30message,
defenseD30message,
defenseRerollContext,
hasShield: !!sData,
shieldLabel: sData?.label || "",
shieldFormula: sData?.formula || "",
shieldDr: sData?.damageReduction || 0,
canAdHocShield: !sData,
})
}
return
}
// Same client: restart for defender loop if attacker boosted past defense
// (either via Grit/bonus die in the attack reaction loop, or via D30 bonus)
if (defenseRoll < attackRollFinal && (attackerHandledBonus || d30ChangedAttack)) {
mulliganRestart = true
}
}
} while (mulliganRestart)
const shieldDamageReduction = shieldBlocked ? shieldReaction?.damageReduction ?? 0 : 0
const outcome = shieldBlocked ? "shielded-hit" : (attackRollFinal > defenseRoll ? "hit" : "miss")
// Only one client should create the comparison message:
// 1. Attacker boosted → attacker's client creates (or socket handler for cross-client)
// 2. Defender boosted → defender's client creates
// 3. Nobody boosted → attacker's client (preferred) or defender's client (fallback if same-client)
// 4. When D30 is pending from GM, the player's client must wait for the socket
// handler — the hook-based values are stale (D30 bonus not applied).
const shouldCreateMessage = !d30PendingFromGM && (
attackerHandledBonus
|| (!attackerHandledBonus && defenderHandledBonus)
|| (!attackerHandledBonus && !defenderHandledBonus && (
(isPrimaryController(defender) && !attackerIsCrossClient)
|| isPrimaryController(attacker)
))
)
if (shouldCreateMessage) {
log("Creating comparison message", { attackerHandledBonus, defenderHandledBonus, isDefenderOwner: defender.isOwner })
await LethalFantasyUtils.compareAttackDefense({
attackerName,
attackerId,
attackRoll: attackRollFinal,
attackWeaponId,
attackRollType,
attackRollKey,
defenderName,
defenderId,
defenderTokenId,
defenseRoll,
outcome,
shieldDamageReduction,
d30Bleed: d30Bleed ? "true" : "",
d30DamageMultiplier: d30DamageMultiplier,
d30DrMultiplier: d30DrMultiplier,
damageTier: damageTier || "standard",
attackD30message
})
} else {
log("Skipping message creation", { attackerHandledBonus, defenderHandledBonus })
}
})
async function _deductResourceOnCast(message, rollTypes, itemType, costFn, resourceField, templateType) {
if (!rollTypes.includes(message.rolls[0]?.options?.rollType)) return
const actorId = message.rolls[0]?.options?.actorId
if (!actorId) return
const actor = game.actors.get(actorId)
if (!actor) return
if (!isPrimaryController(actor)) return
const rollTarget = message.rolls[0]?.options?.rollTarget
const itemId = rollTarget?.id || rollTarget?._id
const item = itemId ? actor.items.get(itemId) : null
if (!item || item.type !== itemType) return
const damageTier = message.rolls[0]?.options?.damageTier || "standard"
const cost = costFn(item, damageTier)
if (cost <= 0) return
const current = Number(foundry.utils.getProperty(actor.system, resourceField)) || 0
const newValue = Math.max(0, current - cost)
await actor.update({ [`system.${resourceField}`]: newValue })
const tierLabel = damageTier === "standard" ? "" : ` (${damageTier})`
const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {
type: templateType, actorName: actor.name, spellName: item.name, tierLabel,
value: cost, oldValue: current, newValue
})
await ChatMessage.create({ content, speaker: ChatMessage.getSpeaker({ actor }) })
}
// Hook: deduct aether when a spell-attack or spell-power roll is posted to chat
Hooks.on("createChatMessage", (message) => _deductResourceOnCast(message,
["spell-attack", "spell-power"], "spell",
(item, tier) => {
const m = { standard: "cost", overpowered: "costOverpowered", overpowered2: "costOverpowered2" }
return Number(item.system?.[m[tier] || "cost"]) || 0
},
"aetherPoints.value", "aetherSpend"
))
// Hook: deduct grace when a miracle-attack or miracle-power roll is posted to chat
Hooks.on("createChatMessage", (message) => _deductResourceOnCast(message,
["miracle-attack", "miracle-power"], "miracle",
(item) => Number(item.system?.level) || 0,
"divinityPoints.value", "graceSpend"
))
// Hook pour appliquer automatiquement les dégâts si une cible est définie
Hooks.on("createChatMessage", async (message) => {
// Vérifier si c'est un message de dégâts avec un defenderId
const defenderId = message.rolls[0]?.options?.defenderId
const isDamage = message.rolls[0]?.options?.rollData?.isDamage
log("Auto-damage hook:", { defenderId, isDamage, rollType: message.rolls[0]?.options?.rollType })
if (!defenderId || !isDamage) return
// Récupérer l'attaquant depuis le roll
const attackerId = message.rolls[0]?.options?.actorId
const attacker = attackerId ? game.actors.get(attackerId) : null
// Déterminer qui doit appliquer les dégâts :
// 1. Si l'attaquant a un propriétaire joueur, seul ce joueur applique
// 2. Si l'attaquant n'a que le MJ comme propriétaire (monstre), seul le MJ applique
const attackerOwners = attacker ? game.users.filter(u =>
u.active && !u.isGM && attacker.testUserPermission(u, "OWNER")
) : []
let shouldApplyDamage = false
if (attackerOwners.length > 0) {
// L'attaquant a des propriétaires joueurs, seul le premier propriétaire applique
shouldApplyDamage = attackerOwners[0].id === game.user.id
} else {
// L'attaquant n'a que le MJ, seul le MJ applique
shouldApplyDamage = game.user.isGM
}
if (!shouldApplyDamage) {
log("Auto-damage hook: Not responsible for applying damage, skipping")
return
}
log("Auto-damage hook: Applying damage as responsible user")
// Attendre l'animation 3D avant d'appliquer les dégâts
if (game?.dice3d) {
await game.dice3d.waitFor3DAnimationByMessageID(message.id)
}
// Récupérer le défenseur
const defender = game.actors.get(defenderId)
if (!defender) {
console.warn("Defender not found:", defenderId)
return
}
// Récupérer les dégâts (utiliser rollTotal qui contient le total calculé)
const damageTotal = message.rolls[0]?.options?.rollTotal || message.rolls[0]?.total || 0
const weaponName = message.rolls[0]?.options?.rollName || "Unknown Weapon"
const attackerName = message.rolls[0]?.options?.actorName || "Unknown Attacker"
const rollType = message.rolls[0]?.options?.rollType
// Lire les effets D30
const d30Bleed = message.rolls[0]?.options?.d30Bleed || false
const d30DamageMultiplier = message.rolls[0]?.options?.d30DamageMultiplier || 1
const d30DrMultiplier = message.rolls[0]?.options?.d30DrMultiplier || 1
// Appliquer le multiplicateur de dégâts D30 au total AVANT DR
const rawDamage = damageTotal * d30DamageMultiplier
// Calculer les DR — les sorts utilisent une DR manuelle saisie par l'utilisateur
const isSpellDamage = rollType === "spell-damage"
const manualDR = message.rolls[0]?.options?.manualDR ?? 0
const extraShieldDr = Number(message.rolls[0]?.options?.extraShieldDr) || 0
// Décomposer les DR en composants
let baseDR = 0
let shieldDR = 0
let magicDR = 0
if (isSpellDamage) {
baseDR = manualDR
} else {
const totalDefDR = defender.computeDamageReduction() || 0
magicDR = defender.getMagicDR() || 0
baseDR = totalDefDR - magicDR // naturalDR + armorDR (ou hpDR + combatDR pour les monstres)
shieldDR = extraShieldDr
}
// Appliquer le multiplicateur de DR D30 si actif — boîte de dialogue
let appliedBaseDR = baseDR
let appliedShieldDR = shieldDR
let appliedMagicDR = magicDR
if (d30DrMultiplier > 1) {
const drResult = await (async () => {
const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/d30-dr-multiplier.hbs", {
multiplier: d30DrMultiplier,
baseDR,
shieldDR,
magicDR,
baseDRMultiplied: baseDR * d30DrMultiplier,
shieldDRMultiplied: shieldDR * d30DrMultiplier,
magicDRMultiplied: magicDR * d30DrMultiplier,
canBase: true,
canShield: shieldDR > 0,
canMagic: magicDR > 0,
baseEnabled: baseDR > 0,
shieldEnabled: shieldDR > 0,
magicEnabled: magicDR > 0
})
const result = await foundry.applications.api.DialogV2.wait({
window: { title: "Apply D30 DR Multiplier" },
classes: ["lethalfantasy"],
content,
buttons: [
{
action: "apply",
label: "Apply Damage",
icon: "fa-solid fa-check",
callback: (event, button) => {
const form = button.form || button.closest("form")
return {
applyBase: form.querySelector("#d30-dr-base")?.checked || false,
applyShield: form.querySelector("#d30-dr-shield")?.checked || false,
applyMagic: form.querySelector("#d30-dr-magic")?.checked || false
}
}
}
],
rejectClose: false
})
return result || { applyBase: false, applyShield: false, applyMagic: false }
})()
appliedBaseDR = drResult.applyBase ? baseDR * d30DrMultiplier : baseDR
appliedShieldDR = drResult.applyShield ? shieldDR * d30DrMultiplier : shieldDR
appliedMagicDR = drResult.applyMagic ? magicDR * d30DrMultiplier : magicDR
}
const totalDR = appliedBaseDR + appliedShieldDR + appliedMagicDR
const finalDamage = Math.max(0, rawDamage - totalDR)
// Prefer the token ID stored in roll options (set at attack time when the exact token is known).
// For unlinked tokens (default for monsters), this ensures we target the right instance even
// when multiple unlinked copies of the same monster type are in combat.
const rollDefenderTokenId = message.rolls[0]?.options?.defenderTokenId
const defenderCombatant = game.combat?.combatants?.find(c => c.actorId === defender.id)
const defenderTokenId = rollDefenderTokenId
?? defenderCombatant?.token?.id
?? canvas.tokens?.placeables?.find(t => t.actor?.id === defender.id)?.id
?? null
// Apply damage. If the current user does not own the defender (e.g. player hitting a GM monster),
// route the HP update to the GM via socket. The confirmation message is still created here
// since all users can create chat messages.
const applyDamageToActor = async (actor) => {
await actor.applyDamage(-finalDamage)
// Create bleeding wound if D30 triggered it
if (d30Bleed && finalDamage > 0 && actor.system.hp?.wounds) {
const wounds = foundry.utils.duplicate(actor.system.hp.wounds)
const slot = wounds.findIndex(w => !w.value && !w.duration)
if (slot !== -1) {
wounds[slot] = { value: finalDamage, duration: finalDamage, description: "Bleeding" }
await actor.update({ "system.hp.wounds": wounds })
}
}
}
if (defender.isOwner) {
const tokenActor = (defenderTokenId
? canvas.tokens?.placeables?.find(t => t.id === defenderTokenId)?.actor
: defenderCombatant?.actor) ?? defender
await applyDamageToActor(tokenActor)
} else {
game.socket.emit(`system.${SYSTEM.id}`, { type: "applyDamage", actorId: defender.id, tokenId: defenderTokenId, damage: -finalDamage })
// Also emit wound creation for bleeding
if (d30Bleed && finalDamage > 0 && defender.system.hp?.wounds) {
game.socket.emit(`system.${SYSTEM.id}`, { type: "applyBleeding", actorId: defender.id, tokenId: defenderTokenId, damage: finalDamage })
}
}
// Build DR text for confirmation message
let drText = ""
if (isSpellDamage) {
drText = manualDR > 0 ? `Spell DR: ${manualDR}` : "No DR applied"
} else {
const parts = []
if (appliedBaseDR > 0) parts.push(`Base DR: ${appliedBaseDR}${d30DrMultiplier > 1 && appliedBaseDR !== baseDR ? ` (×${d30DrMultiplier})` : ""}`)
if (appliedShieldDR > 0) parts.push(`Shield DR: ${appliedShieldDR}${d30DrMultiplier > 1 && appliedShieldDR !== shieldDR ? ` (×${d30DrMultiplier})` : ""}`)
if (appliedMagicDR > 0) parts.push(`Magic DR: ${appliedMagicDR}${d30DrMultiplier > 1 && appliedMagicDR !== magicDR ? ` (×${d30DrMultiplier})` : ""}`)
drText = parts.length > 0 ? parts.join(" + ") : "No DR applied"
}
// Build raw damage text showing D30 multiplier if active
const rawDamageText = d30DamageMultiplier > 1
? `${damageTotal} × ${d30DamageMultiplier} = ${rawDamage}`
: String(damageTotal)
// Créer un message de confirmation (visible to GM only)
const messageContent = await foundry.applications.handlebars.renderTemplate(
"systems/fvtt-lethal-fantasy/templates/damage-applied-message.hbs",
{
targetName: defender.name,
damage: finalDamage,
drText,
weaponName: weaponName,
attackerName: attackerName,
rawDamage: rawDamageText
}
)
// Add bleeding notification
let bleedContent = ""
if (d30Bleed && finalDamage > 0) {
bleedContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", { type: "bleedingNotice", value: finalDamage })
}
await ChatMessage.create({
content: messageContent + bleedContent,
speaker: ChatMessage.getSpeaker({ actor: defender }),
whisper: ChatMessage.getWhisperRecipients("GM")
})
})