import { SYSTEM } from "../config/system.mjs" /** Construit la formule de jet à partir du nombre de dés et du modificateur total. */ function buildFormula(nbDice, totalModifier) { if (totalModifier > 0) return `${nbDice}d8 + ${totalModifier}` if (totalModifier < 0) return `${nbDice}d8 - ${Math.abs(totalModifier)}` return `${nbDice}d8` } /** * Système de dés de Célestopol 1922. * * Formule de base : 2d8 + Spécialisation + modificateurs (aspect, blessures, manual) * Destin (jauge pleine à 8) : 3d8 + modificateurs, puis reset de la jauge à 0 * Dé de la Lune (optionnel) : 1d8 séparé → résultat narratif (Triomphe/Brio/Contrecoup/Catastrophe) */ export class CelestopolRoll extends Roll { static CHAT_TEMPLATE = "systems/fvtt-celestopol/templates/chat-message.hbs" get resultType() { return this.options.resultType } get isSuccess() { return this.resultType === "success" || this.resultType === "critical-success" } get isFailure() { return this.resultType === "failure" || this.resultType === "critical-failure" } get isTie() { return this.resultType === "tie" } get isCriticalSuccess(){ return this.resultType === "critical-success" } get isCriticalFailure(){ return this.resultType === "critical-failure" } get actorId() { return this.options.actorId } get actorName() { return this.options.actorName } get actorImage() { return this.options.actorImage } get skillLabel() { return this.options.skillLabel } get difficulty() { return this.options.difficulty } /** * Ouvre le dialogue de configuration du jet via DialogV2 et exécute le jet. * @param {object} options * @returns {Promise} */ static async prompt(options = {}) { const woundMalus = options.woundMalus ?? 0 const skillValue = options.skillValue ?? 0 const woundLevelId = options.woundLevel ?? 0 const destGaugeFull = options.destGaugeFull ?? false const fortuneValue = options.fortuneValue ?? 0 const isResistance = options.isResistance ?? false const isCombat = options.isCombat ?? false const isRangedDefense = options.isRangedDefense ?? false const weaponType = options.weaponType ?? "melee" const weaponName = options.weaponName ?? null const weaponDegats = options.weaponDegats ?? "0" const woundLabel = woundLevelId > 0 ? game.i18n.localize(SYSTEM.WOUND_LEVELS[woundLevelId]?.label ?? "") : null const modifierChoices = SYSTEM.CONTEXT_MODIFIER_CHOICES.map(c => ({ id: c.id, value: c.value, label: game.i18n.localize(c.label), })) const aspectChoices = [-4, -3, -2, -1, 0, 1, 2, 3, 4].map(v => ({ value: v, label: v > 0 ? `+${v}` : `${v}`, })) const situationChoices = Array.from({ length: 17 }, (_, i) => { const v = i - 8 return { value: v, label: v > 0 ? `+${v}` : `${v}` } }) const dialogContext = { actorName: options.actorName, statLabel: options.statLabel, skillLabel: options.skillLabel, skillValue, woundMalus, woundLabel, difficultyChoices: SYSTEM.DIFFICULTY_CHOICES, defaultDifficulty: options.difficulty ?? "normal", destGaugeFull, defaultRollMoonDie: options.rollMoonDie ?? false, isResistance, isCombat, isRangedDefense, weaponType, weaponName, weaponDegats, modifierChoices, aspectChoices, situationChoices, fortuneValue, } const content = await foundry.applications.handlebars.renderTemplate( "systems/fvtt-celestopol/templates/roll-dialog.hbs", dialogContext ) const skillLocalized = game.i18n.localize(options.skillLabel ?? "") const statLocalized = options.statLabel ? game.i18n.localize(options.statLabel) : null const title = statLocalized ? `${statLocalized} › ${skillLocalized}` : skillLocalized const rollContext = await foundry.applications.api.DialogV2.wait({ window: { title }, classes: ["fvtt-celestopol", "roll-dialog"], content, render: (_event, dialog) => { const wrap = dialog.element.querySelector('.roll-dialog-content') if (!wrap) return function hasMalus(mod, asp, sit) { return woundMalus < 0 || mod < 0 || asp < 0 || sit < 0 } function update() { const rawMod = wrap.querySelector('#modifier')?.value const autoSucc = rawMod === "auto" const modifier = autoSucc ? 0 : (parseInt(rawMod ?? 0) || 0) const aspectMod = parseInt(wrap.querySelector('#aspectModifier')?.value ?? 0) || 0 const situMod = parseInt(wrap.querySelector('#situationMod')?.value ?? 0) || 0 const useDestin = wrap.querySelector('#useDestin')?.checked const useFort = wrap.querySelector('#useFortune')?.checked const puiser = wrap.querySelector('#puiserRessources')?.checked const ndice = useDestin ? 3 : 2 // En résistance : pas de "Puiser" possible const puiserRow = wrap.querySelector('#puiser-row') if (puiserRow) { if (!isResistance && hasMalus(modifier, aspectMod, situMod)) { puiserRow.style.display = '' } else { puiserRow.style.display = 'none' const cb = wrap.querySelector('#puiserRessources') if (cb) cb.checked = false } } const effWound = puiser ? 0 : woundMalus const effMod = puiser ? Math.max(0, modifier) : modifier const effAspect = puiser ? Math.max(0, aspectMod) : aspectMod const effSit = puiser ? Math.max(0, situMod) : situMod const totalMod = skillValue + effWound + effMod + effAspect + effSit let formula if (autoSucc) { formula = game.i18n.localize("CELESTOPOL.Roll.autoSuccess") } else if (useFort) { const fm = totalMod + 8 formula = `1d8` + (fm > 0 ? ` + ${fm}` : fm < 0 ? ` − ${Math.abs(fm)}` : ``) } else { formula = `${ndice}d8` if (totalMod > 0) formula += ` + ${totalMod}` if (totalMod < 0) formula += ` − ${Math.abs(totalMod)}` } const previewEl = wrap.querySelector('#preview-formula') if (previewEl) previewEl.textContent = formula } wrap.querySelectorAll('#modifier, #aspectModifier, #situationMod, #useDestin, #useFortune, #puiserRessources, #corpsPnj') .forEach(el => { el.addEventListener('change', update) el.addEventListener('input', update) }) update() }, buttons: [ { label: game.i18n.localize("CELESTOPOL.Roll.roll"), icon: "fa-solid fa-dice", callback: (event, button) => { return Array.from(button.form.elements).reduce((obj, input) => { if (input.name) { obj[input.name] = input.type === "checkbox" ? input.checked : input.value } return obj }, {}) }, }, ], rejectClose: false, }) if (!rollContext) return null // En combat : Corps PNJ = seuil direct (pas le sélect difficulté) const corpsPnj = isCombat ? (parseInt(rollContext.corpsPnj ?? 7) || 7) : null const difficulty = isCombat ? "combat" : (rollContext.difficulty ?? "normal") const diffConfig = isCombat ? { value: corpsPnj, label: "CELESTOPOL.Combat.corpsPnj" } : (SYSTEM.DIFFICULTY_CHOICES[difficulty] ?? SYSTEM.DIFFICULTY_CHOICES.normal) const autoSuccess = rollContext.modifier === "auto" const modifier = autoSuccess ? 0 : (parseInt(rollContext.modifier ?? 0) || 0) const aspectMod = parseInt(rollContext.aspectModifier ?? 0) || 0 const situationMod = parseInt(rollContext.situationMod ?? 0) || 0 const useDestin = destGaugeFull && (rollContext.useDestin === true || rollContext.useDestin === "true") const useFortune = fortuneValue > 0 && (rollContext.useFortune === true || rollContext.useFortune === "true") const puiserRessources = rollContext.puiserRessources === true || rollContext.puiserRessources === "true" const rollMoonDie = rollContext.rollMoonDie === true || rollContext.rollMoonDie === "true" // En résistance : forcer puiser=false, lune=false, fortune=false, destin=false const effectivePuiser = isResistance ? false : puiserRessources const effectiveMoon = isResistance ? false : rollMoonDie // Puiser dans ses ressources → ignorer tous les malus const effectiveWoundMalus = effectivePuiser ? 0 : woundMalus const effectiveModifier = effectivePuiser ? Math.max(0, modifier) : modifier const effectiveAspectMod = effectivePuiser ? Math.max(0, aspectMod) : aspectMod const effectiveSituationMod = effectivePuiser ? Math.max(0, situationMod) : situationMod // Fortune : 1d8 + 8 ; Destin : 3d8 ; sinon : 2d8 const nbDice = (!isResistance && useDestin) ? 3 : 2 const totalModifier = skillValue + effectiveWoundMalus + effectiveAspectMod + effectiveModifier + effectiveSituationMod const formula = (!isResistance && useFortune) ? buildFormula(1, totalModifier + 8) : buildFormula(nbDice, totalModifier) // Jet du dé de lune séparé (narratif) — pas en résistance let moonDieResult = null let moonFace = null let moonResultType = null if (effectiveMoon) { const moonRoll = await new Roll("1d8").evaluate() moonDieResult = moonRoll.total moonFace = SYSTEM.MOON_DIE_FACES[moonDieResult] ?? null moonResultType = moonFace ? SYSTEM.MOON_RESULT_TYPES[moonFace.result] ?? null : null } const rollData = { ...options, difficulty, difficultyValue: diffConfig.value, modifier: effectiveModifier, aspectMod: effectiveAspectMod, situationMod: effectiveSituationMod, woundMalus: effectiveWoundMalus, autoSuccess, isResistance, isCombat, isRangedDefense, weaponType, weaponName, weaponDegats, useDestin: !isResistance && useDestin, useFortune: !isResistance && useFortune, puiserRessources: effectivePuiser, nbDice: (!isResistance && useFortune) ? 1 : nbDice, formula, rollMode: rollContext.visibility ?? "publicroll", rollMoonDie: effectiveMoon, moonDieResult, moonFace, moonResultType, } const roll = new this(formula, {}, rollData) await roll.evaluate() roll.computeResult() // Test de résistance échoué → cocher automatiquement la prochaine case de blessure const actor = game.actors.get(options.actorId) if (isResistance && actor && roll.options.resultType === "failure") { const nextLvl = (actor.system.blessures.lvl ?? 0) + 1 if (nextLvl <= 8) { await actor.update({ "system.blessures.lvl": nextLvl }) roll.options.woundTaken = nextLvl } } // Mêlée échouée OU défense à distance échouée → joueur prend une blessure if (isCombat && (weaponType === "melee" || isRangedDefense) && actor && roll.options.resultType === "failure") { const nextLvl = (actor.system.blessures.lvl ?? 0) + 1 if (nextLvl <= 8) { await actor.update({ "system.blessures.lvl": nextLvl }) roll.options.woundTaken = nextLvl } } await roll.toMessage({}, { rollMode: rollData.rollMode }) // Batching de toutes les mises à jour de l'acteur en un seul appel réseau if (actor) { const updateData = {} if (rollData.useDestin) { updateData["system.destin.lvl"] = 0 } if (rollData.useFortune) { const currentFortune = actor.system.attributs.fortune.value ?? 0 updateData["system.attributs.fortune.value"] = Math.max(0, currentFortune - 1) } if (rollData.puiserRessources) { const currentSpleen = actor.system.spleen.lvl ?? 0 if (currentSpleen < 8) { updateData["system.spleen.lvl"] = currentSpleen + 1 } } // Mémoriser les préférences updateData["system.prefs.rollMoonDie"] = rollData.rollMoonDie updateData["system.prefs.difficulty"] = difficulty await actor.update(updateData) } return roll } /** * Détermine succès/échec selon la marge (total − seuil). * - Marge ≥ 5 → succès critique * - Marge > 0 → succès * - Marge = 0 → succès (ou égalité en combat) * - Marge ≤ −5 → échec critique * - Marge < 0 → échec */ computeResult() { if (this.options.autoSuccess) { this.options.resultType = "success" this.options.margin = null return } const threshold = this.options.isCombat ? (this.options.difficultyValue ?? 0) : (SYSTEM.DIFFICULTY_CHOICES[this.options.difficulty]?.value ?? 0) if (threshold === 0) { this.options.resultType = "unknown" this.options.margin = null return } const margin = this.total - threshold this.options.margin = margin if (this.options.isCombat && margin === 0) { this.options.resultType = "tie" } else if (margin >= 5) this.options.resultType = "critical-success" else if (margin >= 0) this.options.resultType = "success" else if (margin <= -5) this.options.resultType = "critical-failure" else this.options.resultType = "failure" } /** @override */ async render(chatOptions = {}) { const data = await this._getChatCardData(chatOptions.isPrivate) return foundry.applications.handlebars.renderTemplate(this.constructor.CHAT_TEMPLATE, data) } async _getChatCardData(isPrivate) { const statLabel = this.options.statLabel const skillLabel = this.options.skillLabel const resultType = this.resultType const diceResults = this.dice[0]?.results?.map(r => r.result) ?? [] const diceSum = diceResults.reduce((a, b) => a + b, 0) const threshold = this.options.isCombat ? (this.options.difficultyValue ?? 0) : (SYSTEM.DIFFICULTY_CHOICES[this.options.difficulty]?.value ?? 0) const margin = this.options.margin const woundMalus = this.options.woundMalus ?? 0 const skillValue = this.options.skillValue ?? 0 const woundLevelId = this.options.woundLevel ?? 0 const woundLabel = woundLevelId > 0 ? game.i18n.localize(SYSTEM.WOUND_LEVELS[woundLevelId]?.label ?? "") : null // Dé de lune const moonDieResult = this.options.moonDieResult ?? null const moonFace = this.options.moonFace ?? null const moonResultType = this.options.moonResultType ?? null const resultClassMap = { "critical-success": "critical-success", "success": "success", "tie": "tie", "failure": "failure", "critical-failure": "critical-failure", "unknown": "", } // Libellé de difficulté : en combat, afficher "Corps PNJ : N" const difficultyLabel = this.options.isCombat ? `${game.i18n.localize("CELESTOPOL.Combat.corpsPnj")} : ${threshold}` : game.i18n.localize(SYSTEM.DIFFICULTY_CHOICES[this.options.difficulty]?.label ?? "") return { cssClass: [SYSTEM.id, "dice-roll"].join(" "), actorId: this.actorId, actorName: this.actorName, actorImg: this.actorImage, statLabel: statLabel ? game.i18n.localize(statLabel) : "", skillLabel: skillLabel ? game.i18n.localize(skillLabel) : "", formula: this.formula, total: this.total, diceSum, diceResults, resultType, resultClass: resultClassMap[resultType] ?? "", isSuccess: this.isSuccess, isFailure: this.isFailure, isTie: this.isTie, isCriticalSuccess: this.isCriticalSuccess, isCriticalFailure: this.isCriticalFailure, difficulty: this.options.difficulty, difficultyLabel, difficultyValue: threshold, margin, marginAbs: margin !== null ? Math.abs(margin) : null, marginAbove: margin !== null && margin >= 0, modifier: this.options.modifier ?? 0, autoSuccess: this.options.autoSuccess ?? false, aspectMod: this.options.aspectMod ?? 0, skillValue, useDestin: this.options.useDestin ?? false, useFortune: this.options.useFortune ?? false, puiserRessources: this.options.puiserRessources ?? false, nbDice: this.options.nbDice ?? diceResults.length, woundMalus, woundLabel, isResistance: this.options.isResistance ?? false, isCombat: this.options.isCombat ?? false, weaponName: this.options.weaponName ?? null, weaponDegats: this.options.weaponDegats ?? null, weaponType: this.options.weaponType ?? null, isRangedDefense: this.options.isRangedDefense ?? false, woundTaken: this.options.woundTaken ?? null, // Dé de lune hasMoonDie: moonDieResult !== null, moonDieResult, moonFaceSymbol: moonFace?.symbol ?? "", moonFaceLabel: moonFace ? game.i18n.localize(moonFace.label) : "", moonResultClass: moonResultType?.cssClass ?? "", moonResultLabel: moonResultType ? game.i18n.localize(moonResultType.label) : "", moonResultDesc: moonResultType ? game.i18n.localize(moonResultType.desc) : "", isPrivate, tooltip: isPrivate ? "" : await this.getTooltip(), } } /** @override */ async toMessage(messageData = {}, { rollMode, create = true } = {}) { const skillLocalized = this.skillLabel ? game.i18n.localize(this.skillLabel) : "" const statLocalized = this.options.statLabel ? game.i18n.localize(this.options.statLabel) : "" const flavor = statLocalized ? `${statLocalized} › ${skillLocalized}` : `${skillLocalized}` return super.toMessage({ flavor, ...messageData }, { rollMode }) } }