Files
fvtt-celestopol/module/documents/roll.mjs
LeRatierBretonnier 1e92232013 feat: ajout du Mod. de situation (-8 à +8) dans toutes les fenêtres de jet
- Select situationMod (-8 → +8 par pas de 1) visible sur tous les jets (y compris résistance)
- Intégré dans la prévisualisation de formule (update dynamique)
- Pris en compte dans totalModifier au même titre que les autres mods
- Respecte la règle 'Puiser' (malus ignoré si puiser activé)
- Affiché dans la carte de tchat si non nul (symbole ◈)
- i18n : CELESTOPOL.Roll.situationMod

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 00:44:56 +02:00

446 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<CelestopolRoll|null>}
*/
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) {
return woundMalus < 0 || mod < 0 || asp < 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)) {
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
? `<strong>${statLocalized} ${skillLocalized}</strong>`
: `<strong>${skillLocalized}</strong>`
return super.toMessage({ flavor, ...messageData }, { rollMode })
}
}