ce630feb51
- 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
1269 lines
53 KiB
JavaScript
1269 lines
53 KiB
JavaScript
/**
|
||
* 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 ")
|
||
}
|
||
}
|
||
}
|