Files
fvtt-lethal-fantasy/lethal-fantasy.mjs
T
uberwald ce630feb51 feat: D30 combat effects, spell tiers, small damage removal, token HUD luck/grit
- Replace Knockback with Internal Injury on D30 (5, 10, 15); remove Shield Bash from D30 counter-attacks
- Eliminate small weapon damage: keep only medium damage labelled Damage in sheets, rolls, and chat
- D30 bonus dice (20, 27, 30) auto-resolved before grit/luck/shield decisions; choice dialogs for special strikes
- D30 combat effects: bleeding wounds, damage ×2/×3 before DR, DR ×2/×3 with component picker dialog
- Add hp.wounds to monster schema for bleeding support
- Show Save against spell? checkbox for all save rolls (not just magic users)
- Fix mulligan restart: persistent D30 process flags prevent double-application and allow both sides to react
- For Dice So Nice, show main roll animation before explosion dice for correct ordering
- Spell tier selection: force Standard/Overpowered choice at cast time, tier-specific aether cost, only chosen damage button shown
- Add +1/−1 luck and grit controls to Token HUD
- Fix inconsistent indentation, remove duplicate i18n key, remove unused includesShield return
2026-06-10 07:53:51 +02:00

1269 lines
53 KiB
JavaScript
Raw 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.
/**
* Lethal Fantasy RPG System
* Author: LeRatierBretonnien/Uberwald
*/
import { SYSTEM } from "./module/config/system.mjs"
globalThis.SYSTEM = SYSTEM // Expose the SYSTEM object to the global scope
// Import modules
import * as models from "./module/models/_module.mjs"
import * as documents from "./module/documents/_module.mjs"
import * as applications from "./module/applications/_module.mjs"
import { LethalFantasyCombatTracker, LethalFantasyCombat } from "./module/applications/combat.mjs"
import { Macros } from "./module/macros.mjs"
import { setupTextEnrichers } from "./module/enrichers.mjs"
import { default as LethalFantasyUtils } from "./module/utils.mjs"
export class ClassCounter { static printHello() { console.log("Hello") } static sendJsonPostRequest(e, s) { const t = { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json" }, body: JSON.stringify(s) }; return fetch(e, t).then((e => { if (!e.ok) throw new Error("La requête a échoué avec le statut " + e.status); return e.json() })).catch((e => { throw console.error("Erreur envoi de la requête:", e), e })) } static registerUsageCount(e = game.system.id, s = {}) { if (game.user.isGM) { game.settings.register(e, "world-key", { name: "Unique world key", scope: "world", config: !1, default: "", type: String }); let t = game.settings.get(e, "world-key"); null != t && "" != t && "NONE" != t && "none" != t.toLowerCase() || (t = foundry.utils.randomID(32), game.settings.set(e, "world-key", t)); let a = { name: e, system: game.system.id, worldKey: t, version: game.system.version, language: game.settings.get("core", "language"), remoteAddr: game.data.addresses.remote, nbInstalledModules: game.modules.size, nbActiveModules: game.modules.filter((e => e.active)).length, nbPacks: game.world.packs.size, nbUsers: game.users.size, nbScenes: game.scenes.size, nbActors: game.actors.size, nbPlaylist: game.playlists.size, nbTables: game.tables.size, nbCards: game.cards.size, optionsData: s, foundryVersion: `${game.release.generation}.${game.release.build}` }; this.sendJsonPostRequest("https://www.uberwald.me/fvtt_appcount/count_post.php", a) } } }
Hooks.once("init", function () {
console.info("Lethal Fantasy RPG | Initializing System")
console.info(SYSTEM.ASCII)
globalThis.lethalFantasy = game.system
game.system.CONST = SYSTEM
// Expose the system API
game.system.api = {
applications,
models,
documents,
}
CONFIG.ui.combat = LethalFantasyCombatTracker
CONFIG.Combat.documentClass = LethalFantasyCombat;
CONFIG.Actor.documentClass = documents.LethalFantasyActor
CONFIG.Actor.dataModels = {
character: models.LethalFantasyCharacter,
monster: models.LethalFantasyMonster,
}
CONFIG.Item.documentClass = documents.LethalFantasyItem
CONFIG.Item.dataModels = {
skill: models.LethalFantasySkill,
gift: models.LethalFantasyGift,
weapon: models.LethalFantasyWeapon,
armor: models.LethalFantasyArmor,
shield: models.LethalFantasyShield,
spell: models.LethalFantasySpell,
vulnerability: models.LethalFantasyVulnerability,
equipment: models.LethalFantasyEquipment,
miracle: models.LethalFantasyMiracle
}
// Register sheet application classes
foundry.documents.collections.Actors.unregisterSheet("core", foundry.appv1.sheets.ActorSheet)
foundry.documents.collections.Actors.registerSheet("lethalFantasy", applications.LethalFantasyCharacterSheet, { types: ["character"], makeDefault: true })
foundry.documents.collections.Actors.registerSheet("lethalFantasy", applications.LethalFantasyMonsterSheet, { types: ["monster"], makeDefault: true })
foundry.documents.collections.Items.unregisterSheet("core", foundry.appv1.sheets.ActorSheet)
foundry.documents.collections.Items.registerSheet("lethalFantasy", applications.LethalFantasySkillSheet, { types: ["skill"], makeDefault: true })
foundry.documents.collections.Items.registerSheet("lethalFantasy", applications.LethalFantasyGiftSheet, { types: ["gift"], makeDefault: true })
foundry.documents.collections.Items.registerSheet("lethalFantasy", applications.LethalFantasyVulnerabilitySheet, { types: ["vulnerability"], makeDefault: true })
foundry.documents.collections.Items.registerSheet("lethalFantasy", applications.LethalFantasyWeaponSheet, { types: ["weapon"], makeDefault: true })
foundry.documents.collections.Items.registerSheet("lethalFantasy", applications.LethalFantasySpellSheet, { types: ["spell"], makeDefault: true })
foundry.documents.collections.Items.registerSheet("lethalFantasy", applications.LethalFantasyArmorSheet, { types: ["armor"], makeDefault: true })
foundry.documents.collections.Items.registerSheet("lethalFantasy", applications.LethalFantasyShieldSheet, { types: ["shield"], makeDefault: true })
foundry.documents.collections.Items.registerSheet("lethalFantasy", applications.LethalFantasyEquipmentSheet, { types: ["equipment"], makeDefault: true })
foundry.documents.collections.Items.registerSheet("lethalFantasy", applications.LethalFantasyMiracleSheet, { types: ["miracle"], makeDefault: true })
// Other Document Configuration
CONFIG.ChatMessage.documentClass = documents.LethalFantasyChatMessage
// Dice system configuration
CONFIG.Dice.rolls.push(documents.LethalFantasyRoll)
game.settings.register("lethalFantasy", "worldKey", {
name: "Unique world key",
scope: "world",
config: false,
type: String,
default: "",
})
// Activate socket handler
game.socket.on(`system.${SYSTEM.id}`, LethalFantasyUtils.handleSocketEvent)
setupTextEnrichers()
LethalFantasyUtils.registerHandlebarsHelpers()
LethalFantasyUtils.setHookListeners()
console.info("LETHAL FANTASY | System Initialized")
})
/**
* Perform one-time configuration of system configuration objects.f
*/
function preLocalizeConfig() {
const localizeConfigObject = (obj, keys) => {
for (let o of Object.values(obj)) {
for (let k of keys) {
o[k] = game.i18n.localize(o[k])
}
}
}
}
Hooks.once("ready", function () {
console.info("LETHAL FANTASY | Ready")
// Initialiser la table des résultats D30
documents.D30Roll.initialize()
if (!SYSTEM.DEV_MODE) {
registerWorldCount("lethalFantasy")
}
_showUserGuide()
/**
*
*/
async function _showUserGuide() {
if (game.user.isGM) {
const newVer = game.system.version
}
}
})
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.type
const value = btn.dataset.value
const avantage = btn.dataset.avantage ?? "="
const character = game.user.character
if (type === SYSTEM.ROLL_TYPE.RESOURCE) character.rollResource(value)
else if (type === SYSTEM.ROLL_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
console.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: `<div style="padding:0.5rem 0">
<p style="margin-bottom:0.6rem">${game.i18n.localize("LETHALFANTASY.Combat.spellDRDialogMsg")}</p>
<div style="display:flex;align-items:center;gap:0.5rem">
<label style="font-weight:bold">${game.i18n.localize("LETHALFANTASY.Combat.spellDRLabel")}</label>
<input type="number" name="manualDr" value="0" min="0" style="width:5rem"/>
</div>
</div>`,
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
})
console.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
console.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
console.log("Defense message confirmed, attackData:", attackData)
if (!attackData) {
console.log("No attack data found in message flags")
return
}
const {
attackerId,
attackRoll,
attackerName,
defenderName,
attackWeaponId,
attackRollType,
attackRollKey,
attackD30message,
attackRerollContext,
attackNaturalRoll,
damageTier,
defenderId,
defenderTokenId
} = attackData
let defenseRoll = message.rolls[0]?.options?.rollTotal || message.rolls[0]?.total || 0
const defenseD30message = message.rolls[0]?.options?.D30message || null
console.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, content) => {
await ChatMessage.create({
content,
speaker: ChatMessage.getSpeaker({ actor: actorDocument })
})
}
// 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 combat effects for damage application
let d30Bleed = false
let d30DamageMultiplier = 1
let d30DrMultiplier = 1
do {
mulliganRestart = false
defenderHandledBonus = false
attackerHandledBonus = false
// ── D30 bonus dice (defense) — resolved before grit/luck/shield ───────
if (defenseD30message && !defenseD30Processed && isPrimaryController(defender)) {
const d30Result = await LethalFantasyUtils.processD30BonusDice(defenseD30message, "defense", null, defender)
if (d30Result.modifier) {
defenseRoll += d30Result.modifier
if (d30Result.modifier > 0) {
await createReactionMessage(defender,
`<p><strong>${defenderName}</strong> gains <strong>+${d30Result.modifier}</strong> from D30 bonus die for defense.</p>`
)
}
}
if (d30Result.specialEffect === "auto") {
defenseRoll = attackRollFinal + 1 // auto-block
await createReactionMessage(defender,
`<p><strong>${defenderName}</strong> uses <strong>${d30Result.specialName || "Special Defense"}</strong> from D30 — defense automatically succeeds!</p>`
)
}
if (d30Result.specialEffect === "flag") {
await createReactionMessage(defender,
`<p>D30 — <strong>${d30Result.specialName || "Special Effect"}</strong> triggered for ${defenderName}!</p>`
)
}
if (d30Result.specialEffect === "drMultiplier") {
d30DrMultiplier = d30Result.multiplier
await createReactionMessage(defender,
`<p>D30 — Defense grants <strong>x${d30Result.multiplier} DR</strong> (choose which DR types to multiply when damage is applied)</p>`
)
}
defenseD30Processed = true
}
// ── Defense reaction loop ──────────────────────────────────────────────
if (defender && defenseRoll < attackRollFinal && isPrimaryController(defender) && !isSpellOrMiracle) {
while (defenseRoll < attackRollFinal) {
const currentGrit = Number(defender.system?.grit?.current) || 0
const currentLuck = Number(defender.system?.luck?.current) || 0
const buttons = []
if (currentGrit > 0) {
buttons.push({
action: "grit",
label: `Spend 1 Grit (+1D6) [${currentGrit} left]`,
icon: "fa-solid fa-fist-raised",
callback: () => "grit"
})
}
if (currentLuck > 0) {
buttons.push({
action: "luck",
label: `Spend 1 Luck (+1D6) [${currentLuck} left]`,
icon: "fa-solid fa-clover",
callback: () => "luck"
})
}
buttons.push({
action: "bonusDie",
label: "Add bonus die",
icon: "fa-solid fa-dice",
callback: () => "bonusDie"
})
if (canRerollDefense) {
buttons.push({
action: "rerollDefense",
label: "Re-roll defense (Mulligan)",
icon: "fa-solid fa-rotate-right",
callback: () => "rerollDefense"
})
}
if (canShieldReact) {
buttons.push({
action: "shieldReact",
label: `Roll shield (${shieldData.label})`,
icon: "fa-solid fa-shield",
callback: () => "shieldReact"
})
} else if (canAdHocShield) {
buttons.push({
action: "adHocShield",
label: "Roll ad-hoc shield (choose dice + DR)",
icon: "fa-solid fa-shield-halved",
callback: () => "adHocShield"
})
}
buttons.push({
action: "continue",
label: "Continue (no defense bonus)",
icon: "fa-solid fa-forward",
callback: () => "continue"
})
const choice = await foundry.applications.api.DialogV2.wait({
window: { title: "Defense reactions" },
classes: ["lethalfantasy"],
content: `
<div class="grit-luck-dialog">
<div class="combat-status">
<p><strong>${attackerName}</strong> rolled <strong>${attackRollFinal}</strong></p>
<p><strong>${defenderName}</strong> currently has <strong>${defenseRoll}</strong></p>
${defenseD30message ? `<p class="bonus-info">D30 special: ${defenseD30message.description}</p>` : ""}
</div>
<p class="offer-text">Choose how to improve the defense before resolving the hit.</p>
</div>
`,
buttons,
rejectClose: false
})
if (!choice || choice === "continue") break
defenderHandledBonus = true
if (choice === "grit") {
const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender, total => `<p><strong>${defenderName}</strong> spends 1 Grit and rolls <strong>${total}</strong> for defense.</p>`)
defenseRoll += bonusRoll
await defender.update({ "system.grit.current": currentGrit - 1 })
continue
}
if (choice === "luck") {
const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender, total => `<p><strong>${defenderName}</strong> spends 1 Luck and rolls <strong>${total}</strong> for defense.</p>`)
defenseRoll += bonusRoll
await defender.update({ "system.luck.current": currentLuck - 1 })
continue
}
if (choice === "bonusDie") {
const bonusDie = await LethalFantasyUtils.promptCombatBonusDie(defenderName, "attack", defenseRoll, attackRollFinal)
if (!bonusDie) continue
const bonusRoll = await LethalFantasyUtils.rollBonusDie(bonusDie, defender, (total, formula) => `<p><strong>${defenderName}</strong> adds <strong>${formula.toUpperCase()}</strong> and rolls <strong>${total}</strong> for defense.</p>`)
defenseRoll += bonusRoll
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, `<p><strong>${defenderName}</strong> uses Mulligan and re-rolls defense: <strong>${oldDefenseRoll}</strong> → <strong>${defenseRoll}</strong>. Both sides may now react to the new numbers.</p>`)
// 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,
`<p><strong>${defenderName}</strong> rolls <strong>${shieldData.label}</strong> and adds <strong>${shieldBonus}</strong> to defense (${newDefenseTotal}${attackRollFinal}). <strong>Shield blocked the attack!</strong> Both armor DR and shield DR <strong>${shieldData.damageReduction}</strong> will apply to damage.</p>`
)
} else {
shieldReaction = null
await createReactionMessage(
defender,
`<p><strong>${defenderName}</strong> rolls <strong>${shieldData.label}</strong> and adds <strong>${shieldBonus}</strong> to defense (${newDefenseTotal} < ${attackRollFinal}). Shield did not block — normal hit, armor DR only.</p>`
)
}
}
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,
`<p><strong>${defenderName}</strong> rolls <strong>${adHoc.formula.toUpperCase()}</strong> shield and adds <strong>${shieldBonus}</strong> to defense (${newDefenseTotal}${attackRollFinal}). <strong>Shield blocked the attack!</strong> Both armor DR and shield DR <strong>${adHoc.damageReduction}</strong> will apply to damage.</p>`
)
} else {
shieldReaction = null
await createReactionMessage(
defender,
`<p><strong>${defenderName}</strong> rolls <strong>${adHoc.formula.toUpperCase()}</strong> shield and adds <strong>${shieldBonus}</strong> to defense (${newDefenseTotal} < ${attackRollFinal}). Shield did not block — normal hit, armor DR only.</p>`
)
}
}
}
}
if (mulliganRestart) continue
// ── D30 bonus dice (attack) — resolved before grit/luck ────────────────
if (attackD30message && !attackD30Processed && isPrimaryController(attacker)) {
const d30Result = await LethalFantasyUtils.processD30BonusDice(attackD30message, "attack", attackNaturalRoll, attacker)
if (d30Result.modifier) {
attackRollFinal += d30Result.modifier
if (d30Result.modifier > 0) {
await createReactionMessage(attacker,
`<p><strong>${attackerName}</strong> gains <strong>+${d30Result.modifier}</strong> from D30 bonus die for attack.</p>`
)
}
}
if (d30Result.specialEffect === "auto") {
attackRollFinal = defenseRoll + 1 // auto-hit
await createReactionMessage(attacker,
`<p><strong>${attackerName}</strong> uses <strong>${d30Result.specialName || "Special Strike"}</strong> from D30 — attack automatically hits!</p>`
)
}
if (d30Result.specialEffect === "flag") {
await createReactionMessage(attacker,
`<p>D30 — <strong>${d30Result.specialName || "Special Effect"}</strong> triggered for ${attackerName}!</p>`
)
}
if (d30Result.specialEffect === "bleed") {
d30Bleed = true
await createReactionMessage(attacker,
`<p>D30 — <strong>Bleeding/Internal Injury</strong> on hit! Damage past DR will cause a bleeding wound.</p>`
)
}
if (d30Result.specialEffect === "damageMultiplier") {
d30DamageMultiplier = d30Result.multiplier
await createReactionMessage(attacker,
`<p>D30 — <strong>x${d30Result.multiplier} damage</strong> before damage reduction!</p>`
)
}
attackD30Processed = true
}
// ── Attack reaction loop ───────────────────────────────────────────────
if (!defenderHandledBonus && 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 choice = await foundry.applications.api.DialogV2.wait({
window: { title: "Attack reactions" },
classes: ["lethalfantasy"],
content: `
<div class="grit-luck-dialog">
<div class="combat-status">
<p><strong>${attackerName}</strong> currently has <strong>${attackRollFinal}</strong></p>
<p><strong>${defenderName}</strong> rolled <strong>${defenseRoll}</strong></p>
${attackD30message ? `<p class="bonus-info">D30 special: ${attackD30message.description}</p>` : ""}
</div>
<p class="offer-text">Choose how to improve the attack before resolving the combat result.</p>
</div>
`,
buttons,
rejectClose: false
})
if (!choice || choice === "continue") break
attackerHandledBonus = true
if (choice === "grit") {
const attackBonus = await LethalFantasyUtils.rollBonusDie("1d6", attacker, total => `<p><strong>${attackerName}</strong> spends 1 Grit and rolls <strong>${total}</strong> for attack.</p>`)
attackRollFinal += attackBonus
await attacker.update({ "system.grit.current": currentGrit - 1 })
continue
}
if (choice === "bonusDie") {
const bonusDie = await LethalFantasyUtils.promptCombatBonusDie(attackerName, "defense", attackRollFinal, defenseRoll)
if (!bonusDie) continue
const attackBonus = await LethalFantasyUtils.rollBonusDie(bonusDie, attacker, (total, formula) => `<p><strong>${attackerName}</strong> adds <strong>${formula.toUpperCase()}</strong> and rolls <strong>${total}</strong> for attack.</p>`)
attackRollFinal += attackBonus
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, `<p><strong>${attackerName}</strong> uses Mulligan and re-rolls attack: <strong>${oldAttackRoll}</strong> → <strong>${attackRollFinal}</strong>. Both sides may now react to the new numbers.</p>`)
// Restart the full comparison so both sides can react to the new roll
mulliganRestart = true
break
}
}
}
} while (mulliganRestart)
const shieldDamageReduction = shieldBlocked ? shieldReaction.damageReduction : 0
const outcome = shieldBlocked ? "shielded-hit" : (attackRollFinal > defenseRoll ? "hit" : "miss")
// Créer le message de comparaison - uniquement par le client qui a géré le dernier bonus
// Priorité: attaquant si il a géré le bonus, sinon défenseur si il a géré le bonus, sinon défenseur
const shouldCreateMessage = attackerHandledBonus || (!attackerHandledBonus && defenderHandledBonus) || (!attackerHandledBonus && !defenderHandledBonus && isPrimaryController(defender))
if (shouldCreateMessage) {
console.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"
})
} else {
console.log("Skipping message creation", { attackerHandledBonus, defenderHandledBonus })
}
})
// Hook: deduct aether when a spell-attack or spell-power roll is posted to chat
Hooks.on("createChatMessage", async (message) => {
if (!["spell-attack", "spell-power"].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
// Only the primary controller (player owner or GM) handles this
const activePlayerOwners = game.users.filter(u => u.active && !u.isGM && actor.testUserPermission(u, "OWNER"))
const isPrimary = activePlayerOwners.length > 0
? activePlayerOwners[0].id === game.user.id
: game.user.isGM
if (!isPrimary) return
const rollTarget = message.rolls[0]?.options?.rollTarget
const spellId = rollTarget?.id || rollTarget?._id
const spell = spellId ? actor.items.get(spellId) : null
if (!spell || spell.type !== "spell") return
const damageTier = message.rolls[0]?.options?.damageTier || "standard"
const tierCostMap = { standard: "cost", overpowered: "costOverpowered", overpowered2: "costOverpowered2" }
const costField = tierCostMap[damageTier] || "cost"
const cost = Number(spell.system?.[costField]) || 0
if (cost <= 0) return
const currentAether = Number(actor.system.aetherPoints?.value) || 0
const newAether = Math.max(0, currentAether - cost)
await actor.update({ "system.aetherPoints.value": newAether })
const tierLabel = damageTier === "standard" ? "" : ` (${damageTier})`
await ChatMessage.create({
content: `<p>🔮 <strong>${actor.name}</strong> casts <em>${spell.name}${tierLabel}</em> — spends <strong>${cost}</strong> Aether <span style="color:#888;">(${currentAether}${newAether})</span>.</p>`,
speaker: ChatMessage.getSpeaker({ actor })
})
})
// Hook: deduct grace when a miracle-attack or miracle-power roll is posted to chat
Hooks.on("createChatMessage", async (message) => {
if (!["miracle-attack", "miracle-power"].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
const activePlayerOwners = game.users.filter(u => u.active && !u.isGM && actor.testUserPermission(u, "OWNER"))
const isPrimary = activePlayerOwners.length > 0
? activePlayerOwners[0].id === game.user.id
: game.user.isGM
if (!isPrimary) return
const rollTarget = message.rolls[0]?.options?.rollTarget
const miracleId = rollTarget?.id || rollTarget?._id
const miracle = miracleId ? actor.items.get(miracleId) : null
if (!miracle || miracle.type !== "miracle") return
const cost = Number(miracle.system?.level) || 0
if (cost <= 0) return
const currentGrace = Number(actor.system.divinityPoints?.value) || 0
const newGrace = Math.max(0, currentGrace - cost)
await actor.update({ "system.divinityPoints.value": newGrace })
await ChatMessage.create({
content: `<p>✨ <strong>${actor.name}</strong> invokes <em>${miracle.name}</em> — spends <strong>${cost}</strong> Grace <span style="color:#888;">(${currentGrace}${newGrace})</span>.</p>`,
speaker: ChatMessage.getSpeaker({ actor })
})
})
// 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
console.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) {
console.log("Auto-damage hook: Not responsible for applying damage, skipping")
return
}
console.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 checks = {
base: true,
shield: shieldDR > 0,
magic: magicDR > 0
}
const html = `
<div class="grit-luck-dialog">
<p><strong>D30 DR Multiplier ×${d30DrMultiplier}</strong></p>
<p>Choose which DR types to multiply:</p>
<label style="display:block;margin:0.3rem 0">
<input type="checkbox" id="d30-dr-base" ${checks.base ? "checked" : ""} ${baseDR <= 0 ? "disabled" : ""}>
Base DR (Armor/Natural): ${baseDR}×${d30DrMultiplier} = ${baseDR * d30DrMultiplier}
</label>
<label style="display:block;margin:0.3rem 0">
<input type="checkbox" id="d30-dr-shield" ${checks.shield ? "checked" : ""} ${shieldDR <= 0 ? "disabled" : ""}>
Shield DR: ${shieldDR}×${d30DrMultiplier} = ${shieldDR * d30DrMultiplier}
</label>
<label style="display:block;margin:0.3rem 0">
<input type="checkbox" id="d30-dr-magic" ${checks.magic ? "checked" : ""} ${magicDR <= 0 ? "disabled" : ""}>
Magic DR: ${magicDR}×${d30DrMultiplier} = ${magicDR * d30DrMultiplier}
</label>
</div>
`
const result = await foundry.applications.api.DialogV2.wait({
window: { title: "Apply D30 DR Multiplier" },
classes: ["lethalfantasy"],
content: html,
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 = `<p><strong>Bleeding:</strong> Wound of ${finalDamage} HP for ${finalDamage} seconds.</p>`
}
await ChatMessage.create({
content: messageContent + bleedContent,
speaker: ChatMessage.getSpeaker({ actor: defender }),
whisper: ChatMessage.getWhisperRecipients("GM")
})
})
/**
* Create a macro when dropping an entity on the hotbar
* Item - open roll dialog
* Actor - open actor sheet
* Journal - open journal sheet
*/
Hooks.on("hotbarDrop", (bar, data, slot) => {
if (["Actor", "Item", "JournalEntry", "roll", "rollDamage", "rollAttack"].includes(data.type)) {
Macros.createLethalFantasyMacro(data, slot);
return false
}
})
/* -------------------------------------------- */
/**
* Inject the Lethal Fantasy dice tray into the chat sidebar.
*/
Hooks.on("renderChatLog", (_chatLog, html) => applications.injectDiceTray(_chatLog, html))
/**
* Register world usage statistics
* @param {string} registerKey
*/
async function registerWorldCount(registerKey) {
if (game.user.isGM) {
try {
ClassCounter.registerUsageCount(game.system.id, {})
} catch {
console.log("No usage log ")
}
}
}