/** * Célestopol 1922 — Système FoundryVTT * * Célestopol 1922 est un jeu de rôle édité par Antre-Monde Éditions. * Ce système FoundryVTT est une implémentation indépendante et n'est pas * affilié à Antre-Monde Éditions, * mais a été réalisé avec l'autorisation d'Antre-Monde Éditions. * * @author LeRatierBretonnien * @copyright 2025–2026 LeRatierBretonnien * @license CC BY-NC-SA 4.0 – https://creativecommons.org/licenses/by-nc-sa/4.0/ */ 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 } /** * Convertit le niveau de dégâts d'une arme en nombre de blessures de base. * Règle : une attaque réussie inflige toujours 1 blessure, plus le bonus de dégâts. * @param {string|number|null} weaponDegats * @returns {number|null} */ static getIncomingWounds(weaponDegats) { const raw = `${weaponDegats ?? "0"}` const bonus = Number.parseInt(raw, 10) if (!Number.isFinite(bonus)) return null return Math.max(0, 1 + bonus) } /** * Retourne la protection totale de l'armure équipée pour un acteur. * @param {Actor|null} actor * @returns {number} */ static getActorArmorProtection(actor) { if (!actor) return 0 if (typeof actor.system?.getArmorMalus === "function") { return Math.abs(actor.system.getArmorMalus()) } const derivedArmorMalus = actor.system?.armorMalus if (Number.isFinite(derivedArmorMalus)) { return Math.abs(derivedArmorMalus) } const armures = actor.itemTypes?.armure ?? [] return armures .filter(a => a.system.equipped) .reduce((sum, a) => sum + Math.abs(a.system.protection ?? a.system.malus ?? 0), 0) } /** * 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 armorMalus = options.armorMalus ?? 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 availableTargets = options.availableTargets ?? [] const isRangedAttack = isCombat && !isRangedDefense && weaponType === "distance" 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 rangedModChoices = SYSTEM.RANGED_MODIFIERS.map(m => ({ id: m.id, value: m.value, label: game.i18n.localize(m.label), })) const factionAspectChoices = game.celestopol?.getFactionAspectSummary(options.actorId ? game.actors.get(options.actorId) : null) ?.availableAspectChoices ?? [] const dialogContext = { actorName: options.actorName, statLabel: options.statLabel, skillLabel: options.skillLabel, skillValue, woundMalus, woundLabel, isCombat, isRangedDefense, isRangedAttack, weaponType, weaponName, weaponDegats, modifierChoices, aspectChoices, situationChoices, rangedModChoices, factionAspectChoices, availableTargets, fortuneValue, armorMalus, destGaugeFull, defaultRollMoonDie: options.rollMoonDie ?? false, isResistance, } 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 // Sélection de cible PNJ : masque le champ Corps PNJ (valeur cachée) const targetSelect = wrap.querySelector('#targetSelect') const corpsPnjRow = wrap.querySelector('#corps-pnj-row') const targetConfirmedRow = wrap.querySelector('#target-confirmed-row') const targetConfirmedName = wrap.querySelector('#target-confirmed-name') function applyTargetSelection() { if (!targetSelect) return const selectedOption = targetSelect.options[targetSelect.selectedIndex] const val = parseFloat(selectedOption?.dataset.corps ?? "") const corpsPnjInput = wrap.querySelector('#corpsPnj') if (targetSelect.value && !isNaN(val)) { // Cible sélectionnée : masquer la valeur, afficher le nom if (corpsPnjRow) corpsPnjRow.style.display = 'none' if (targetConfirmedRow) targetConfirmedRow.style.display = '' if (targetConfirmedName) targetConfirmedName.textContent = selectedOption?.text ?? '' if (corpsPnjInput) { corpsPnjInput.value = val corpsPnjInput.dispatchEvent(new Event('input')) } } else { // Saisie manuelle if (corpsPnjRow) corpsPnjRow.style.display = '' if (targetConfirmedRow) targetConfirmedRow.style.display = 'none' } } if (targetSelect) { targetSelect.addEventListener('change', applyTargetSelection) applyTargetSelection() } function hasMalus(mod, asp, sit, ranged) { return woundMalus < 0 || armorMalus < 0 || mod < 0 || asp < 0 || sit < 0 || ranged < 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 selectedFactionAspect = wrap.querySelector('#factionAspectId')?.selectedOptions?.[0] const factionAspectBonus = parseInt(selectedFactionAspect?.dataset.value ?? 0) || 0 const situMod = parseInt(wrap.querySelector('#situationMod')?.value ?? 0) || 0 const rangedMod = parseInt(wrap.querySelector('#rangedMod')?.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, rangedMod)) { 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 effArmor = puiser ? 0 : armorMalus const effRanged = puiser ? Math.max(0, rangedMod) : rangedMod const totalMod = skillValue + effWound + effMod + effAspect + factionAspectBonus + effSit + effArmor + effRanged 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, #factionAspectId, #situationMod, #rangedMod, #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 ; sinon seuil fixe = 11 const corpsPnj = isCombat ? (parseInt(rollContext.corpsPnj ?? 7) || 7) : null const difficulty = isCombat ? "combat" : "standard" const diffConfig = isCombat ? { value: corpsPnj, label: "CELESTOPOL.Combat.corpsPnj" } : { value: 11, label: "CELESTOPOL.Roll.threshold" } const autoSuccess = rollContext.modifier === "auto" const modifier = autoSuccess ? 0 : (parseInt(rollContext.modifier ?? 0) || 0) const aspectMod = parseInt(rollContext.aspectModifier ?? 0) || 0 const factionAspectId = typeof rollContext.factionAspectId === "string" ? rollContext.factionAspectId : "" const selectedFactionAspect = factionAspectChoices.find(choice => choice.id === factionAspectId) ?? null const factionAspectBonus = selectedFactionAspect?.value ?? 0 const factionAspectLabel = selectedFactionAspect?.label ?? "" const situationMod = parseInt(rollContext.situationMod ?? 0) || 0 const rangedMod = isRangedAttack ? (parseInt(rollContext.rangedMod ?? 0) || 0) : 0 const isOpposition = !isCombat && (rollContext.isOpposition === true || rollContext.isOpposition === "true") 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" const selectedCombatTargetId = typeof rollContext.targetSelect === "string" ? rollContext.targetSelect : "" const selectedCombatTarget = selectedCombatTargetId ? availableTargets.find(t => t.id === selectedCombatTargetId) ?? null : null const targetActorId = selectedCombatTarget?.id || "" const targetActorName = selectedCombatTarget?.name || "" // 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 effectiveArmorMalus = effectivePuiser ? 0 : armorMalus const effectiveModifier = effectivePuiser ? Math.max(0, modifier) : modifier const effectiveAspectMod = effectivePuiser ? Math.max(0, aspectMod) : aspectMod const effectiveSituationMod = effectivePuiser ? Math.max(0, situationMod) : situationMod const effectiveRangedMod = effectivePuiser ? Math.max(0, rangedMod) : rangedMod // Fortune : 1d8 + 8 ; Destin : 3d8 ; sinon : 2d8 const nbDice = (!isResistance && useDestin) ? 3 : 2 const totalModifier = skillValue + effectiveWoundMalus + effectiveAspectMod + effectiveModifier + factionAspectBonus + effectiveSituationMod + effectiveArmorMalus + effectiveRangedMod 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, factionAspectId, factionAspectLabel, factionAspectBonus, situationMod: effectiveSituationMod, woundMalus: effectiveWoundMalus, autoSuccess, isResistance, isOpposition, isCombat, isRangedDefense, weaponType, weaponName, weaponDegats, targetActorId, targetActorName, availableTargets, rangedMod: effectiveRangedMod, 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 (protagonistes uniquement — le modèle NPC n'a pas de champ prefs) if (actor.type === "character") { 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). * Seuil : 11 pour les tests normaux, Corps PNJ pour le combat. * Pas de succès/échec critique — seul le Dé de la Lune produit des résultats exceptionnels. */ computeResult() { if (this.options.autoSuccess) { this.options.resultType = "success" this.options.margin = null return } // En test d'opposition : pas de résultat calculé — le MJ décide if (this.options.isOpposition) { this.options.resultType = "opposition" this.options.margin = null return } const threshold = this.options.isCombat ? (this.options.difficultyValue ?? 0) : 11 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 >= 0) { this.options.resultType = "success" } 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) : 11 const margin = this.options.margin const woundMalus = this.options.woundMalus ?? 0 const armorMalus = this.options.armorMalus ?? 0 const skillValue = this.options.skillValue ?? 0 const woundLevelId = this.options.woundLevel ?? 0 const weaponDegats = `${this.options.weaponDegats ?? "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 = { "success": "success", "tie": "tie", "failure": "failure", "opposition": "opposition", "unknown": "", } const isOpposition = this.options.isOpposition ?? false const isWeaponHit = (this.options.isCombat ?? false) && !(this.options.isRangedDefense ?? false) && this.isSuccess const incomingWounds = isWeaponHit ? this.constructor.getIncomingWounds(weaponDegats) : null const hasVariableDamage = isWeaponHit && incomingWounds === null const targetActorId = this.options.targetActorId ?? "" const targetActorName = this.options.targetActorName ?? "" const availableTargets = (this.options.availableTargets ?? []).map(target => ({ ...target, selected: target.id === targetActorId, })) const selectedTargetActor = targetActorId ? game.actors.get(targetActorId) : null const selectedTargetProtection = selectedTargetActor ? this.constructor.getActorArmorProtection(selectedTargetActor) : null const selectedTargetAppliedWounds = (incomingWounds !== null && selectedTargetActor) ? Math.max(0, incomingWounds - selectedTargetProtection) : null // Libellé de difficulté : en combat "Corps PNJ : N", en opposition "vs ?", sinon "Seuil : 11" const difficultyLabel = this.options.isCombat ? `${game.i18n.localize("CELESTOPOL.Combat.corpsPnj")} : ${threshold}` : isOpposition ? `${game.i18n.localize("CELESTOPOL.Roll.oppositionVs")}` : `${game.i18n.localize("CELESTOPOL.Roll.threshold")} : 11` 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, isOpposition, difficulty: this.options.difficulty, difficultyLabel, difficultyValue: isOpposition ? null : 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, factionAspectLabel: this.options.factionAspectLabel ?? "", factionAspectBonus: this.options.factionAspectBonus ?? 0, skillValue, useDestin: this.options.useDestin ?? false, useFortune: this.options.useFortune ?? false, puiserRessources: this.options.puiserRessources ?? false, nbDice: this.options.nbDice ?? diceResults.length, woundMalus, armorMalus, woundLabel, isResistance: this.options.isResistance ?? false, isCombat: this.options.isCombat ?? false, weaponName: this.options.weaponName ?? null, weaponDegats, weaponType: this.options.weaponType ?? null, isRangedDefense: this.options.isRangedDefense ?? false, woundTaken: this.options.woundTaken ?? null, situationMod: this.options.situationMod ?? 0, rangedMod: this.options.rangedMod ?? 0, hasDamageSummary: isWeaponHit, incomingWounds, incomingWoundsDisplay: incomingWounds ?? "1 + X", hasVariableDamage, canApplyWeaponDamage: incomingWounds !== null, targetActorId, targetActorName, selectedTargetProtection, selectedTargetAppliedWounds, availableTargets, // 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 }) } /** * Lance le dé de la Lune de façon autonome (hors test de spécialisation). * Affiche un carte de chat avec le résultat narratif ET l'interprétation chance. * @param {Actor|null} actor Acteur initiateur (pour le speaker du message) */ static async rollMoonStandalone(actor = null) { const roll = await new Roll("1d8").evaluate() const result = roll.total const face = SYSTEM.MOON_DIE_FACES[result] ?? null const resultType = face ? SYSTEM.MOON_RESULT_TYPES[face.result] ?? null : null const isGoodFortune = result <= 4 const templateData = { result, moonFaceSymbol: face?.symbol ?? "", moonFaceLabel: face ? game.i18n.localize(face.label) : "", moonResultLabel: resultType ? game.i18n.localize(resultType.label) : "", moonResultDesc: resultType ? game.i18n.localize(resultType.desc) : "", moonResultClass: resultType?.cssClass ?? "", isGoodFortune, actorName: actor?.name ?? null, } const content = await foundry.applications.handlebars.renderTemplate( "systems/fvtt-celestopol/templates/moon-standalone.hbs", templateData ) const speaker = actor ? ChatMessage.getSpeaker({ actor }) : { alias: game.i18n.localize("CELESTOPOL.Moon.standalone") } await ChatMessage.create({ content, speaker, rolls: [roll], style: CONST.CHAT_MESSAGE_STYLES?.ROLL ?? 5, }) } }