/** * Donjon & Cie - Systeme FoundryVTT * * Donjon & Cie est un jeu de role edite par John Doe. * Ce systeme FoundryVTT est une implementation independante et n'est pas * affilie a John Doe. * * @author LeRatierBretonnien * @copyright 2025–2026 LeRatierBretonnien * @license CC BY-NC-SA 4.0 – https://creativecommons.org/licenses/by-nc-sa/4.0/ */ import { DonjonEtCieUtility } from "./donjon-et-cie-utility.mjs"; import { DONJON_ET_CIE } from "./donjon-et-cie-config.mjs"; export class DonjonEtCieRolls { static async #createChatCard(actor, template, context) { const content = await foundry.applications.handlebars.renderTemplate(template, context); await ChatMessage.create({ speaker: ChatMessage.getSpeaker({ actor }), user: game.user.id, content }); } static #selectKeptValue(values, mode, favorable = "low") { if (!values.length) return null; if (mode === "normal") return values[0]; const selector = favorable === "low" ? (mode === "avantage" ? Math.min : Math.max) : (mode === "avantage" ? Math.max : Math.min); return selector(...values); } static #getModeLabel(mode) { if (mode === "avantage") return "Avantage"; if (mode === "desavantage") return "Desavantage"; return null; } static #applyFavorMode(mode) { if (mode === "desavantage") return "normal"; return "avantage"; } static async #resolveFormulaRoll(formula, data = {}, { mode = "normal", favorable = "high" } = {}) { const rollCount = mode === "normal" ? 1 : 2; const rolls = await Promise.all(Array.from({ length: rollCount }, () => (new Roll(formula, data)).evaluate())); const values = rolls.map((roll) => roll.total); const kept = this.#selectKeptValue(values, mode, favorable); const keptIndex = Math.max(0, values.findIndex((value) => value === kept)); const keptRoll = rolls[keptIndex] ?? rolls[0]; return { rolls, values, kept, keptIndex, keptRoll, mode, formula: keptRoll.formula }; } static async #resolveCharacteristic(actor, characteristicKey, { mode = "normal" } = {}) { const characteristic = actor.system.caracteristiques?.[characteristicKey]; if (!characteristic) return null; const target = Number(characteristic.value ?? 0); const rollCount = mode === "normal" ? 1 : 2; const roll = await (new Roll(`${rollCount}d20`)).evaluate(); const values = roll.dice[0]?.results?.map((result) => result.result) ?? []; const kept = this.#selectKeptValue(values, mode, "low"); const success = kept <= target; return { characteristic, characteristicKey, target, values, kept, success, mode, isNaturalOne: kept === 1, isNaturalTwenty: kept === 20 }; } static async #resolveFavorBoost(actor, favorKey, mode = "normal") { if (!favorKey) return null; const label = DonjonEtCieUtility.getFavorLabel(favorKey); const path = `system.faveurs.${favorKey}.delta`; const before = Number(foundry.utils.getProperty(actor, path) ?? 0); if (!before) { ui.notifications.warn(`Aucune faveur disponible pour ${label}.`); return null; } const resolved = await this.#resolveFormulaRoll(`1d${before}`, {}, { favorable: "high" }); const result = resolved.kept; const degraded = result <= 3; const after = degraded ? DonjonEtCieUtility.degradeUsageDie(before) : before; if (after !== before) { await actor.update({ [path]: after }); } return { key: favorKey, label, before, after, result, degraded, stable: !degraded, effectiveMode: this.#applyFavorMode(mode), modeBefore: mode, modeAfter: this.#applyFavorMode(mode), note: degraded ? "Le coup de pouce reste anonyme : un collegue du departement a donne l'info utile." : "Le coup de pouce tient bon : nommez le collegue, ses trois traits et la relation pour le trombinoscope." }; } static async useFavorService(actor, favorKey) { if (!favorKey) return null; const label = DonjonEtCieUtility.getFavorLabel(favorKey); const path = `system.faveurs.${favorKey}.delta`; const before = Number(foundry.utils.getProperty(actor, path) ?? 0); if (!before) { ui.notifications.warn(`Aucune faveur disponible pour ${label}.`); return null; } const after = DonjonEtCieUtility.degradeUsageDie(before); await actor.update({ [path]: after }); await this.#createChatCard(actor, "systems/fvtt-donjon-et-cie/templates/chat/favor-card.hbs", { title: game.i18n.localize("DNC.Roll.Favor"), subtitle: label, kindLabel: "Service", before: DonjonEtCieUtility.formatUsageDie(before), after: DonjonEtCieUtility.formatUsageDie(after), autoSpent: true, note: "La faveur est brulee pour obtenir directement l'aide souhaitee, a la discretion du MJ." }); return { key: favorKey, label, before, after }; } static async #ensureFocus(actor) { const focusDelta = Number(actor.system.magie?.focus?.delta ?? 0); const focusResult = Number(actor.system.magie?.focus?.resultat ?? 0); const focusSceneId = actor.system.magie?.focus?.sceneId ?? ""; const currentSceneId = DonjonEtCieUtility.getCurrentSceneId(); const sameScene = focusSceneId === currentSceneId; const activeFocus = sameScene ? focusResult : 0; if (!focusDelta) { return { delta: 0, activeValue: 0, rolled: false, before: 0, after: 0, degraded: false }; } if (sameScene) { return { delta: focusDelta, activeValue: activeFocus, rolled: false, before: focusDelta, after: focusDelta, degraded: false }; } const resolved = await this.#resolveFormulaRoll(`1d${focusDelta}`, {}, { favorable: "high" }); const result = resolved.kept; const degraded = result <= 3; const after = degraded ? DonjonEtCieUtility.degradeUsageDie(focusDelta) : focusDelta; const updateData = { "system.magie.focus.resultat": result, "system.magie.focus.sceneId": currentSceneId }; if (after !== focusDelta) { updateData["system.magie.focus.delta"] = after; } await actor.update(updateData); return { delta: after, activeValue: result, rolled: true, before: focusDelta, after, degraded, values: resolved.values }; } static async rollCharacteristic(actor, characteristicKey, { mode = "normal", label = null, favorKey = "" } = {}) { const favor = await this.#resolveFavorBoost(actor, favorKey, mode); const effectiveMode = favor?.effectiveMode ?? mode; const result = await this.#resolveCharacteristic(actor, characteristicKey, { mode: effectiveMode }); if (!result) return null; await this.#createChatCard(actor, "systems/fvtt-donjon-et-cie/templates/chat/roll-card.hbs", { title: label ?? "Jet de caracteristique", subtitle: result.characteristic.label, formula: result.values.length > 1 ? "2d20" : "1d20", mode: effectiveMode, modeLabel: this.#getModeLabel(effectiveMode), target: result.target, targetPillLabel: "Cible", targetPillValue: result.target, values: result.values, kept: result.kept, keptPillLabel: "Garde", keptPillValue: result.kept, success: result.success, favorLabel: favor?.label ?? null, favorNote: favor?.note ?? null, details: [ { label: "Caracteristique", value: result.characteristic.label }, { label: "Valeur cible", value: result.target }, ...(favor ? [ { label: "Faveur", value: favor.label }, { label: "Dé de faveur", value: favor.result }, { label: "Avant", value: DonjonEtCieUtility.formatUsageDie(favor.before) }, { label: "Apres", value: DonjonEtCieUtility.formatUsageDie(favor.after) } ] : []) ] }); return { ...result, favor, mode: effectiveMode }; } static async rollInitiative(actor, { mode = "normal" } = {}) { const dex = Number(actor.system.caracteristiques?.dexterite?.value ?? 0); const sheetBonus = Number(actor.system.combat?.initiativeBonus ?? 0); const result = await this.#resolveFormulaRoll("1d20 + @dex + @sheetBonus", { dex, sheetBonus }, { mode, favorable: "high" }); const dieValues = result.rolls.map((roll) => roll.dice[0]?.results?.[0]?.result ?? roll.total); const die = dieValues[result.keptIndex] ?? dieValues[0] ?? result.kept; let syncedCombat = null; const activeCombat = game.combats?.contents?.find((combat) => combat.active); const combatant = activeCombat?.combatants?.find((entry) => entry.actorId === actor.id); if (combatant) { await activeCombat.setInitiative(combatant.id, result.kept); const ordered = [...activeCombat.combatants].sort((a, b) => (b.initiative ?? -Infinity) - (a.initiative ?? -Infinity)); syncedCombat = { name: activeCombat.name, initiative: result.kept, rank: ordered.findIndex((entry) => entry.id === combatant.id) + 1, total: ordered.length }; } await this.#createChatCard(actor, "systems/fvtt-donjon-et-cie/templates/chat/initiative-card.hbs", { title: game.i18n.localize("DNC.Roll.Initiative"), actorName: actor.name, total: result.kept, formula: result.rolls.length > 1 ? `2 × ${result.formula}` : result.formula, die, dieValues, dex, bonus: sheetBonus, mode: result.mode, modeLabel: this.#getModeLabel(result.mode), syncedCombat }); return { total: result.kept, die, dieValues, dex, bonus: sheetBonus, mode: result.mode, syncedCombat }; } static async rollHitDice(actor) { const formula = String(actor.system.sante?.dv ?? "").trim(); if (!formula) return null; let roll; try { roll = await (new Roll(formula)).evaluate(); } catch (error) { ui.notifications.error(`Formule de DV invalide : ${formula}`); throw error; } const dieValues = roll.dice.flatMap((die) => die.results?.map((result) => result.result) ?? []); await this.#createChatCard(actor, "systems/fvtt-donjon-et-cie/templates/chat/hit-dice-card.hbs", { title: game.i18n.localize("DNC.Roll.HitDice"), actorName: actor.name, formula: roll.formula, total: roll.total, dieValues }); return { formula: roll.formula, total: roll.total, dieValues }; } static async rollWeapon(actor, item, { mode = "normal", favorKey = "" } = {}) { const characteristicKey = DonjonEtCieUtility.getWeaponCharacteristicKey(item.system.categorie); const favor = await this.#resolveFavorBoost(actor, favorKey, mode); const effectiveMode = favor?.effectiveMode ?? mode; const result = await this.#resolveCharacteristic(actor, characteristicKey, { mode: effectiveMode }); if (!result) return null; const characteristicLabel = DONJON_ET_CIE.characteristics[characteristicKey]?.label ?? characteristicKey; const characteristicShort = DONJON_ET_CIE.characteristics[characteristicKey]?.short ?? characteristicKey; await this.#createChatCard(actor, "systems/fvtt-donjon-et-cie/templates/chat/roll-card.hbs", { title: `${game.i18n.localize("DNC.Roll.Attack")} : ${item.name}`, subtitle: DONJON_ET_CIE.weaponCategoryOptions[item.system.categorie] ?? item.system.categorie, formula: result.values.length > 1 ? "2d20" : "1d20", mode: effectiveMode, modeLabel: this.#getModeLabel(effectiveMode), target: result.target, targetPillLabel: characteristicShort, targetPillValue: result.target, values: result.values, kept: result.kept, keptPillLabel: "Jet", keptPillValue: result.kept, success: result.success, favorLabel: favor?.label ?? null, favorNote: favor?.note ?? null, showDamageButton: result.success && Boolean(item.system.degats), itemUuid: item.uuid, details: [ { label: "Arme", value: item.name }, { label: "Caracteristique", value: characteristicLabel }, { label: `Valeur de ${characteristicLabel}`, value: result.target }, { label: "Degats", value: item.system.degats || "—" }, { label: "Portee", value: item.system.portee || "—" }, ...(favor ? [ { label: "Faveur", value: favor.label }, { label: "Dé de faveur", value: favor.result }, { label: "Avant", value: DonjonEtCieUtility.formatUsageDie(favor.before) }, { label: "Apres", value: DonjonEtCieUtility.formatUsageDie(favor.after) } ] : []) ] }); return { ...result, favor, mode: effectiveMode }; } static async rollDamage(actor, item, { mode = "normal" } = {}) { if (!item.system.degats) return null; const actorBonus = Number(actor?.system?.combat?.degatsBonus ?? 0); const totalBonus = actorBonus; const formula = totalBonus ? `${item.system.degats} + ${totalBonus}` : item.system.degats; const result = await this.#resolveFormulaRoll(formula, {}, { mode, favorable: "high" }); const targets = DonjonEtCieUtility.getSceneDamageTargets(); const rollDieLabels = result.rolls.map((roll) => { const dieValues = roll.dice.flatMap((die) => die.results?.map((dieResult) => dieResult.result) ?? []); return dieValues.length ? dieValues.join(" + ") : String(roll.total ?? "—"); }); const keptDieLabel = rollDieLabels[result.keptIndex] ?? rollDieLabels[0] ?? String(result.kept); await this.#createChatCard(actor ?? item.actor, "systems/fvtt-donjon-et-cie/templates/chat/damage-card.hbs", { title: `${game.i18n.localize("DNC.Roll.Damage")} : ${item.name}`, subtitle: item.system.portee || item.type, formula: result.rolls.length > 1 ? `2 × ${result.formula}` : result.formula, mode: result.mode, modeLabel: this.#getModeLabel(result.mode), rollDieLabels, keptDieLabel, values: result.values, total: result.kept, bonus: totalBonus, baseDamage: item.system.degats, sourceLabel: item.name, targets, hasTargets: targets.length > 0 }); return { total: result.kept, formula: result.formula, bonus: totalBonus, values: result.values, mode: result.mode }; } static async applyDamage(target, { damage = 0, useArmor = false, sourceLabel = "" } = {}) { const actor = target?.actor ?? target; if (!actor || actor.documentName !== "Actor") { ui.notifications.warn(game.i18n.localize("DNC.Chat.InvalidDamageTarget")); return null; } const targetName = target?.name ?? actor.name; const applied = await actor.applyIncomingDamage(damage, { useArmor }); await this.#createChatCard(actor, "systems/fvtt-donjon-et-cie/templates/chat/damage-application-card.hbs", { title: game.i18n.localize("DNC.Chat.DamageApplied"), subtitle: targetName, sourceLabel, total: applied.hpDamage, incoming: applied.incoming, useArmor: applied.useArmor, armorLabel: applied.armorLabel, armorAvailable: applied.armorAvailable, armorBefore: applied.armorBefore, armorAbsorbed: applied.armorAbsorbed, armorAfter: applied.armorAfter, pvBefore: applied.pvBefore, pvAfter: applied.pvAfter, pvMax: applied.pvMax }); return { actor, targetName, ...applied }; } static async rollSpell(actor, item, { mode = "normal", favorKey = "" } = {}) { const characteristicKey = item.system.caracteristique || "intelligence"; const focus = await this.#ensureFocus(actor); const rank = Number(actor.system.anciennete?.rang ?? actor.system.sante?.dv ?? 0); const cost = Number(item.system.coutPv ?? 0); const autoDisadvantage = cost > rank; const baseMode = autoDisadvantage ? "desavantage" : mode; const favor = await this.#resolveFavorBoost(actor, favorKey, baseMode); const effectiveMode = favor?.effectiveMode ?? baseMode; const result = await this.#resolveCharacteristic(actor, characteristicKey, { mode: effectiveMode }); if (!result) return null; const currentPv = Number(actor.system.sante?.pv?.value ?? 0); const availableMagicHp = currentPv + focus.activeValue; if (cost > availableMagicHp) { ui.notifications.warn("Le lanceur ne dispose pas d'assez de PV et de focus pour payer ce sort."); return null; } const characteristicShort = DONJON_ET_CIE.characteristics[characteristicKey]?.short ?? characteristicKey; const success = result.isNaturalTwenty ? false : result.success; const focusSpent = result.isNaturalOne ? 0 : Math.min(cost, focus.activeValue); const focusRemaining = Math.max(focus.activeValue - focusSpent, 0); const spentPv = result.isNaturalOne ? 0 : Math.max(cost - focusSpent, 0); const remainingPv = Math.max(currentPv - spentPv, 0); const updateData = {}; if (spentPv !== 0) { updateData["system.sante.pv.value"] = remainingPv; } if (focusSpent !== 0) { updateData["system.magie.focus.resultat"] = focusRemaining; } if (Object.keys(updateData).length) { await actor.update(updateData); } const canInvokeChaos = !success && !result.isNaturalTwenty && Number(actor.system.magie?.chaos?.delta ?? 12) >= 4; const specialNote = result.isNaturalTwenty ? "20 naturel : la magie tourne a la catastrophe, au choix du MJ." : (result.isNaturalOne ? "1 naturel : effet benefique possible ; par defaut, aucun PV n'est depense." : null); await this.#createChatCard(actor, "systems/fvtt-donjon-et-cie/templates/chat/spell-card.hbs", { title: `${game.i18n.localize("DNC.Roll.Spell")} : ${item.name}`, subtitle: item.system.portee || "Sortilege", formula: result.values.length > 1 ? "2d20" : "1d20", mode: effectiveMode, modeLabel: this.#getModeLabel(effectiveMode), autoDisadvantage, autoDisadvantageCanceled: autoDisadvantage && Boolean(favor), favorLabel: favor?.label ?? null, favorNote: favor?.note ?? null, targetPillLabel: characteristicShort, targetPillValue: result.target, values: result.values, kept: result.kept, keptPillLabel: "Jet", keptPillValue: result.kept, success, specialNote, showDamageButton: success && Boolean(item.system.degats), showChaosButton: canInvokeChaos, itemUuid: item.uuid, actorUuid: actor.uuid, details: [ { label: "Sortilege", value: item.name }, { label: "Caracteristique", value: result.characteristic.label }, { label: "Valeur de la caracteristique", value: result.target }, { label: "Cout en PV", value: cost }, { label: "Focus", value: focus.activeValue > 0 ? `${focus.activeValue} (${DonjonEtCieUtility.formatUsageDie(focus.before)})` : "—" }, { label: "Focus depense", value: focusSpent }, { label: "Focus restant", value: focusRemaining }, { label: "PV depenses", value: spentPv }, { label: "PV restants", value: remainingPv }, { label: "Rang du lanceur", value: rank }, { label: "Difficulte", value: item.system.difficulte ?? 0 }, { label: "Effet", value: item.system.effet || "—" }, ...(favor ? [ { label: "Faveur", value: favor.label }, { label: "Dé de faveur", value: favor.result }, { label: "Avant", value: DonjonEtCieUtility.formatUsageDie(favor.before) }, { label: "Apres", value: DonjonEtCieUtility.formatUsageDie(favor.after) } ] : []) ], focusRolled: focus.rolled, focusValue: focus.activeValue, focusSpent, focusRemaining, focusBeforeLabel: DonjonEtCieUtility.formatUsageDie(focus.before), focusAfterLabel: DonjonEtCieUtility.formatUsageDie(focus.after), focusDegraded: focus.degraded, spentPv, remainingPv }); return { ...result, success, spentPv, remainingPv, cost, focus, focusSpent, focusRemaining, favor, mode: effectiveMode }; } static async rollSpellChaos(actor, item) { const before = Number(actor?.system?.magie?.chaos?.delta ?? 12); if (!before || before < 4) { ui.notifications.warn("Le Chaos n'est pas disponible pour ce sort."); return null; } const resolved = await this.#resolveFormulaRoll(`1d${before}`, {}, { favorable: "high" }); const result = resolved.kept; const degraded = result <= 3; const after = degraded ? DonjonEtCieUtility.degradeUsageDie(before) : before; const chaosEntry = DONJON_ET_CIE.chaosTable[result] ?? null; if (after !== before) { await actor.update({ "system.magie.chaos.delta": after }); } await this.#createChatCard(actor, "systems/fvtt-donjon-et-cie/templates/chat/chaos-card.hbs", { title: `Chaos : ${item.name}`, value: result, before: DonjonEtCieUtility.formatUsageDie(before), after: DonjonEtCieUtility.formatUsageDie(after), chaosEntry, degraded, exhausted: after < 4, itemName: item.name }); return { result, before, after, degraded, chaosEntry }; } static async rollUsage(item, { mode = "normal" } = {}) { const before = Number(item.system.delta ?? 0); if (!before) return null; const resolved = await this.#resolveFormulaRoll(`1d${before}`, {}, { mode, favorable: "high" }); const result = resolved.kept; const degraded = result <= 3; const after = degraded ? DonjonEtCieUtility.degradeUsageDie(before) : before; const updateData = {}; if (item.type === "armure") { updateData["system.resultatProtection"] = result; } if (after !== before) { updateData["system.delta"] = after; } if (Object.keys(updateData).length) { await item.update(updateData); } await this.#createChatCard(item.actor, "systems/fvtt-donjon-et-cie/templates/chat/usage-card.hbs", { title: `${game.i18n.localize("DNC.Roll.Usage")} : ${item.name}`, value: result, values: resolved.values, mode: resolved.mode, modeLabel: this.#getModeLabel(resolved.mode), before: DonjonEtCieUtility.formatUsageDie(before), after: DonjonEtCieUtility.formatUsageDie(after), protectionStored: item.type === "armure" ? result : null, degraded, exhausted: after === 0 }); return { result, values: resolved.values, mode: resolved.mode, before, after, degraded }; } }