948 lines
37 KiB
JavaScript
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
|
|
})
|
|
}
|
|
}
|