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 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 woundLabel = woundLevelId > 0 ? game.i18n.localize(SYSTEM.WOUND_LEVELS[woundLevelId]?.label ?? "") : null const modifierChoices = [-4, -3, -2, -1, 0, 1, 2, 3, 4].map(v => ({ 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, modifierChoices, 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) { return woundMalus < 0 || mod < 0 || asp < 0 } function update() { const modifier = parseInt(wrap.querySelector('#modifier')?.value ?? 0) || 0 const aspectMod = parseInt(wrap.querySelector('#aspectModifier')?.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 // Afficher/masquer le bloc "Puiser" selon les malus actifs const puiserRow = wrap.querySelector('#puiser-row') if (puiserRow) { if (hasMalus(modifier, aspectMod)) { 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 totalMod = skillValue + effWound + effMod + effAspect let formula 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, #useDestin, #useFortune, #puiserRessources') .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 const difficulty = rollContext.difficulty ?? "normal" const diffConfig = SYSTEM.DIFFICULTY_CHOICES[difficulty] ?? SYSTEM.DIFFICULTY_CHOICES.normal const modifier = parseInt(rollContext.modifier ?? 0) || 0 const aspectMod = parseInt(rollContext.aspectModifier ?? 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" // Puiser dans ses ressources → ignorer tous les malus const effectiveWoundMalus = puiserRessources ? 0 : woundMalus const effectiveModifier = puiserRessources ? Math.max(0, modifier) : modifier const effectiveAspectMod = puiserRessources ? Math.max(0, aspectMod) : aspectMod // Fortune : 1d8 + 8 ; Destin : 3d8 ; sinon : 2d8 const nbDice = useDestin ? 3 : 2 const totalModifier = skillValue + effectiveWoundMalus + effectiveAspectMod + effectiveModifier const formula = useFortune ? buildFormula(1, totalModifier + 8) : buildFormula(nbDice, totalModifier) // Jet du dé de lune séparé (narratif) let moonDieResult = null let moonFace = null let moonResultType = null if (rollMoonDie) { 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, woundMalus: effectiveWoundMalus, useDestin, useFortune, puiserRessources, nbDice: useFortune ? 1 : nbDice, formula, rollMode: rollContext.visibility ?? "publicroll", rollMoonDie, moonDieResult, moonFace, moonResultType, } const roll = new this(formula, {}, rollData) await roll.evaluate() roll.computeResult() await roll.toMessage({}, { rollMode: rollData.rollMode }) // Destin utilisé → vider la jauge (reset à 0) const actor = game.actors.get(options.actorId) if (useDestin && actor) { await actor.update({ "system.destin.lvl": 0, "system.destin.d1.checked": false, "system.destin.d2.checked": false, "system.destin.d3.checked": false, "system.destin.d4.checked": false, "system.destin.d5.checked": false, "system.destin.d6.checked": false, "system.destin.d7.checked": false, "system.destin.d8.checked": false, }) } // Fortune utilisée → décrémenter de 1 (min 0) if (useFortune && actor) { const currentFortune = actor.system.attributs.fortune.value ?? 0 await actor.update({ "system.attributs.fortune.value": Math.max(0, currentFortune - 1) }) } // Puiser dans ses ressources → coche une case de spleen if (puiserRessources && actor) { const currentSpleen = actor.system.spleen.lvl ?? 0 if (currentSpleen < 8) { const newLvl = currentSpleen + 1 const key = `s${newLvl}` await actor.update({ "system.spleen.lvl": newLvl, [`system.spleen.${key}.checked`]: true, }) } } // Mémoriser les préférences sur l'acteur if (actor) { await actor.update({ "system.prefs.rollMoonDie": rollMoonDie, "system.prefs.difficulty": difficulty, }) } return roll } /** * Détermine succès/échec selon la marge (total − seuil). * - Marge ≥ 5 → succès critique * - Marge ≥ 0 → succès * - Marge ≤ −5 → échec critique * - Marge < 0 → échec */ computeResult() { const threshold = 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 (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 = 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", "failure": "failure", "critical-failure": "critical-failure", "unknown": "", } 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, isCriticalSuccess: this.isCriticalSuccess, isCriticalFailure: this.isCriticalFailure, difficulty: this.options.difficulty, difficultyLabel: game.i18n.localize(SYSTEM.DIFFICULTY_CHOICES[this.options.difficulty]?.label ?? ""), difficultyValue: threshold, margin, marginAbs: margin !== null ? Math.abs(margin) : null, marginAbove: margin !== null && margin >= 0, modifier: this.options.modifier ?? 0, 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, // 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 }) } }