/** * 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 attackWeaponId = message.rolls[0]?.rollTarget?.weapon?.id || message.rolls[0]?.rollTarget?.weapon?._id const attackRollType = message.rolls[0]?.type const attackRollKey = message.rolls[0]?.rollTarget?.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 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") const defenseMsg = { type: "requestDefense", attackerName, attackerId, defenderName, weaponName, attackRoll, attackWeaponId, attackRollType, attackRollKey, attackD30result, attackD30message, attackRerollContext, 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 // 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: `
${game.i18n.localize("LETHALFANTASY.Combat.spellDRDialogMsg")}
${attackerName} rolled ${attackRoll}
${defenderName} currently has ${defenseRoll}
${defenseD30message ? `D30 special: ${defenseD30message.description}
` : ""}Choose how to improve the defense before resolving the hit.
${defenderName} spends 1 Grit and rolls ${total} for defense.
`) defenseRoll += bonusRoll await defender.update({ "system.grit.current": currentGrit - 1 }) continue } if (choice === "luck") { const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender, (total) => `${defenderName} spends 1 Luck and rolls ${total} for defense.
`) defenseRoll += bonusRoll await defender.update({ "system.luck.current": currentLuck - 1 }) continue } if (choice === "bonusDie") { const bonusDie = await LethalFantasyUtils.promptCombatBonusDie(defenderName, "attack", defenseRoll, attackRoll) if (!bonusDie) continue const bonusRoll = await LethalFantasyUtils.rollBonusDie(bonusDie, defender, (total, formula) => `${defenderName} adds ${formula.toUpperCase()} and rolls ${total} for defense.
`) 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, `${defenderName} uses Mulligan and re-rolls defense: ${oldDefenseRoll} → ${defenseRoll}.
`) continue } if (choice === "shieldReact" && canShieldReact) { const shieldBonus = await LethalFantasyUtils.rollBonusDie(shieldData.formula, defender) const newDefenseTotal = defenseRoll + shieldBonus defenseRoll = newDefenseTotal canShieldReact = false if (newDefenseTotal >= attackRoll) { shieldBlocked = true shieldReaction = { damageReduction: shieldData.damageReduction, label: shieldData.label, bonus: shieldBonus } await createReactionMessage( defender, `${defenderName} rolls ${shieldData.label} and adds ${shieldBonus} to defense (${newDefenseTotal} ≥ ${attackRoll}). Shield blocked the attack! Both armor DR and shield DR ${shieldData.damageReduction} will apply to damage.
` ) } else { shieldReaction = null await createReactionMessage( defender, `${defenderName} rolls ${shieldData.label} and adds ${shieldBonus} to defense (${newDefenseTotal} < ${attackRoll}). Shield did not block — normal hit, armor DR only.
` ) } } if (choice === "adHocShield") { const adHoc = await LethalFantasyUtils.promptAdHocShield(defenderName, attackRoll, defenseRoll) if (!adHoc) continue const shieldBonus = await LethalFantasyUtils.rollBonusDie(adHoc.formula, defender) const newDefenseTotal = defenseRoll + shieldBonus defenseRoll = newDefenseTotal canShieldReact = false canAdHocShield = false if (newDefenseTotal >= attackRoll) { shieldBlocked = true shieldReaction = { damageReduction: adHoc.damageReduction, label: `${adHoc.formula.toUpperCase()} shield`, bonus: shieldBonus } await createReactionMessage( defender, `${defenderName} rolls ${adHoc.formula.toUpperCase()} shield and adds ${shieldBonus} to defense (${newDefenseTotal} ≥ ${attackRoll}). Shield blocked the attack! Both armor DR and shield DR ${adHoc.damageReduction} will apply to damage.
` ) } else { shieldReaction = null await createReactionMessage( defender, `${defenderName} rolls ${adHoc.formula.toUpperCase()} shield and adds ${shieldBonus} to defense (${newDefenseTotal} < ${attackRoll}). Shield did not block — normal hit, armor DR only.
` ) } } } } let attackRollFinal = attackRoll let attackerHandledBonus = false // Si l'attaquant est un personnage qui perd et a du Grit // Seulement si l'utilisateur actuel est le propriétaire de l'attaquant (pas le MJ) if (!defenderHandledBonus && attacker && attackRollFinal <= defenseRoll && isPrimaryController(attacker)) { let canRerollAttack = LethalFantasyUtils.hasD30Reroll(attackD30message) 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", 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: `${attackerName} currently has ${attackRollFinal}
${defenderName} rolled ${defenseRoll}
${attackD30message ? `D30 special: ${attackD30message.description}
` : ""}Choose how to improve the attack before resolving the combat result.
${attackerName} spends 1 Grit and rolls ${total} for attack.
`) 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) => `${attackerName} adds ${formula.toUpperCase()} and rolls ${total} for attack.
`) 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, `${attackerName} uses Mulligan and re-rolls attack: ${oldAttackRoll} → ${attackRollFinal}.
`) } } } 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 }) } 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 cost = Number(spell.system?.cost) || 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 }) await ChatMessage.create({ content: `🔮 ${actor.name} casts ${spell.name} — spends ${cost} Aether (${currentAether} → ${newAether}).
`, 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: `✨ ${actor.name} invokes ${miracle.name} — spends ${cost} Grace (${currentGrace} → ${newGrace}).
`, 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 // 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 const armorDR = isSpellDamage ? manualDR : (defender.computeDamageReduction() || 0) const totalDR = isSpellDamage ? manualDR : armorDR + extraShieldDr const finalDamage = Math.max(0, damageTotal - 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. if (defender.isOwner) { // Resolve the token actor: prefer lookup by token ID (exact match for unlinked monsters), // fall back to combatant actor, then base world actor. const tokenActor = (defenderTokenId ? canvas.tokens?.placeables?.find(t => t.id === defenderTokenId)?.actor : defenderCombatant?.actor) ?? defender await tokenActor.applyDamage(-finalDamage) } else { game.socket.emit(`system.${SYSTEM.id}`, { type: "applyDamage", actorId: defender.id, tokenId: defenderTokenId, damage: -finalDamage }) } // 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: isSpellDamage ? (manualDR > 0 ? `Spell DR: ${manualDR}` : "No DR applied") : (totalDR > 0 ? `Armor DR: ${armorDR}${extraShieldDr > 0 ? ` + Shield DR: ${extraShieldDr}` : ""}` : ""), weaponName: weaponName, attackerName: attackerName, rawDamage: damageTotal } ) await ChatMessage.create({ content: messageContent, 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 ") } } }