IMplémentation de la ajorité des remarques de Nepherius

This commit is contained in:
2026-04-06 17:48:30 +02:00
parent a3f7b11f82
commit 1022597bf8
51 changed files with 1900 additions and 443 deletions

View File

@@ -1,4 +1,5 @@
export { default as CelestopolActor } from "./actor.mjs"
export { default as CelestopolItem } from "./item.mjs"
export { default as CelestopolChatMessage } from "./chat-message.mjs"
export { default as CelestopolCombat } from "./combat.mjs"
export { CelestopolRoll } from "./roll.mjs"

View File

@@ -1,6 +1,22 @@
export default class CelestopolActor extends Actor {
/** @override */
getRollData() {
return this.toObject(false).system
// Inclure les valeurs dérivées (initiative, résistances…) calculées par prepareDerivedData
return { ...this.toObject(false).system, initiative: this.system.initiative ?? 0 }
}
/**
* Override de l'initiative : valeur déterministe (pas de dé).
* Personnage : 4 + Mobilité + Inspiration
* PNJ : Corps.res
* @override
*/
async rollInitiative() {
if (!game.combat) return null
const combatant = game.combat.combatants.find(c => c.actorId === this.id)
if (!combatant) return null
const initiative = this.system.initiative ?? 0
await combatant.update({ initiative })
return combatant
}
}

View File

@@ -0,0 +1,53 @@
const SYSTEM_ID = "fvtt-celestopol"
export default class CelestopolCombat extends Combat {
/** @override — Initiative déterministe, message stylé maison */
async rollInitiative(ids, { updateTurn = true } = {}) {
ids = typeof ids === "string" ? [ids] : ids
const combatants = ids.map(id => this.combatants.get(id)).filter(Boolean)
if (!combatants.length) return this
const updates = []
for (const combatant of combatants) {
const actor = combatant.actor
if (!actor) continue
const value = actor.system.initiative ?? 0
updates.push({ _id: combatant.id, initiative: value })
await CelestopolCombat._postInitiativeMessage(combatant, actor, value)
}
if (updates.length) await this.updateEmbeddedDocuments("Combatant", updates)
if (updateTurn && this.turn !== null) await this.update({ turn: this.turn })
return this
}
static async _postInitiativeMessage(combatant, actor, value) {
const sys = actor.system
let detail
if (actor.type === "character") {
const mob = sys.stats?.corps?.mobilite?.value ?? 0
const insp = sys.stats?.coeur?.inspiration?.value ?? 0
detail = `4 + ${mob} (${game.i18n.localize("CELESTOPOL.Skill.mobilite")}) + ${insp} (${game.i18n.localize("CELESTOPOL.Skill.inspiration")})`
} else {
const corps = sys.stats?.corps?.res ?? value
detail = `${game.i18n.localize("CELESTOPOL.Stat.corps")} : ${corps}`
}
const content = await renderTemplate(
`systems/${SYSTEM_ID}/templates/chat-initiative.hbs`,
{
actorName: combatant.name ?? actor.name,
actorImg: actor.img,
value,
detail,
}
)
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
content,
style: CONST.CHAT_MESSAGE_STYLES.OTHER,
flags: { [SYSTEM_ID]: { type: "initiative" } },
})
}
}

View File

@@ -35,18 +35,21 @@ export class CelestopolRoll extends Roll {
* @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 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 woundLabel = woundLevelId > 0
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
@@ -63,6 +66,11 @@ export class CelestopolRoll extends Roll {
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 dialogContext = {
actorName: options.actorName,
@@ -71,20 +79,22 @@ export class CelestopolRoll extends Roll {
skillValue,
woundMalus,
woundLabel,
difficultyChoices: SYSTEM.DIFFICULTY_CHOICES,
defaultDifficulty: options.difficulty ?? "normal",
destGaugeFull,
defaultRollMoonDie: options.rollMoonDie ?? false,
isResistance,
isCombat,
isRangedDefense,
isRangedAttack,
weaponType,
weaponName,
weaponDegats,
modifierChoices,
aspectChoices,
situationChoices,
rangedModChoices,
availableTargets,
fortuneValue,
armorMalus,
destGaugeFull,
defaultRollMoonDie: options.rollMoonDie ?? false,
isResistance,
}
const content = await foundry.applications.handlebars.renderTemplate(
@@ -104,8 +114,40 @@ export class CelestopolRoll extends Roll {
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
// 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(targetSelect.value)
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() {
@@ -114,6 +156,7 @@ export class CelestopolRoll extends Roll {
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 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
@@ -122,7 +165,7 @@ export class CelestopolRoll extends Roll {
// En résistance : pas de "Puiser" possible
const puiserRow = wrap.querySelector('#puiser-row')
if (puiserRow) {
if (!isResistance && hasMalus(modifier, aspectMod, situMod)) {
if (!isResistance && hasMalus(modifier, aspectMod, situMod, rangedMod)) {
puiserRow.style.display = ''
} else {
puiserRow.style.display = 'none'
@@ -135,7 +178,9 @@ export class CelestopolRoll extends Roll {
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
const effArmor = puiser ? 0 : armorMalus
const effRanged = puiser ? Math.max(0, rangedMod) : rangedMod
const totalMod = skillValue + effWound + effMod + effAspect + effSit + effArmor + effRanged
let formula
if (autoSucc) {
@@ -153,7 +198,7 @@ export class CelestopolRoll extends Roll {
if (previewEl) previewEl.textContent = formula
}
wrap.querySelectorAll('#modifier, #aspectModifier, #situationMod, #useDestin, #useFortune, #puiserRessources, #corpsPnj')
wrap.querySelectorAll('#modifier, #aspectModifier, #situationMod, #rangedMod, #useDestin, #useFortune, #puiserRessources, #corpsPnj')
.forEach(el => {
el.addEventListener('change', update)
el.addEventListener('input', update)
@@ -179,16 +224,18 @@ export class CelestopolRoll extends Roll {
if (!rollContext) return null
// En combat : Corps PNJ = seuil direct (pas le sélect difficulté)
// En combat : Corps PNJ = seuil direct ; sinon seuil fixe = 11
const corpsPnj = isCombat ? (parseInt(rollContext.corpsPnj ?? 7) || 7) : null
const difficulty = isCombat ? "combat" : (rollContext.difficulty ?? "normal")
const difficulty = isCombat ? "combat" : "standard"
const diffConfig = isCombat
? { value: corpsPnj, label: "CELESTOPOL.Combat.corpsPnj" }
: (SYSTEM.DIFFICULTY_CHOICES[difficulty] ?? SYSTEM.DIFFICULTY_CHOICES.normal)
: { 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 situationMod = parseInt(rollContext.situationMod ?? 0) || 0
const rangedMod = isRangedAttack ? (parseInt(rollContext.rangedMod ?? 0) || 0) : 0
const isOpposition = !isCombat && !isResistance && (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"
@@ -200,13 +247,15 @@ export class CelestopolRoll extends Roll {
// 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 + effectiveSituationMod
const totalModifier = skillValue + effectiveWoundMalus + effectiveAspectMod + effectiveModifier + effectiveSituationMod + effectiveArmorMalus + effectiveRangedMod
const formula = (!isResistance && useFortune)
? buildFormula(1, totalModifier + 8)
: buildFormula(nbDice, totalModifier)
@@ -232,11 +281,13 @@ export class CelestopolRoll extends Roll {
woundMalus: effectiveWoundMalus,
autoSuccess,
isResistance,
isOpposition,
isCombat,
isRangedDefense,
weaponType,
weaponName,
weaponDegats,
rangedMod: effectiveRangedMod,
useDestin: !isResistance && useDestin,
useFortune: !isResistance && useFortune,
puiserRessources: effectivePuiser,
@@ -294,9 +345,11 @@ export class CelestopolRoll extends Roll {
}
}
// Mémoriser les préférences
updateData["system.prefs.rollMoonDie"] = rollData.rollMoonDie
updateData["system.prefs.difficulty"] = difficulty
// 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)
}
@@ -306,11 +359,8 @@ export class CelestopolRoll extends 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
* 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) {
@@ -318,9 +368,15 @@ export class CelestopolRoll extends Roll {
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)
: (SYSTEM.DIFFICULTY_CHOICES[this.options.difficulty]?.value ?? 0)
: 11
if (threshold === 0) {
this.options.resultType = "unknown"
this.options.margin = null
@@ -330,10 +386,11 @@ export class CelestopolRoll extends Roll {
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"
} else if (margin >= 0) {
this.options.resultType = "success"
} else {
this.options.resultType = "failure"
}
}
/** @override */
@@ -350,7 +407,7 @@ export class CelestopolRoll extends Roll {
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)
: 11
const margin = this.options.margin
const woundMalus = this.options.woundMalus ?? 0
const skillValue = this.options.skillValue ?? 0
@@ -365,18 +422,21 @@ export class CelestopolRoll extends Roll {
const moonResultType = this.options.moonResultType ?? null
const resultClassMap = {
"critical-success": "critical-success",
"success": "success",
"tie": "tie",
"failure": "failure",
"critical-failure": "critical-failure",
"unknown": "",
"success": "success",
"tie": "tie",
"failure": "failure",
"opposition": "opposition",
"unknown": "",
}
// Libellé de difficulté : en combat, afficher "Corps PNJ : N"
const isOpposition = this.options.isOpposition ?? false
// 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}`
: game.i18n.localize(SYSTEM.DIFFICULTY_CHOICES[this.options.difficulty]?.label ?? "")
: isOpposition
? `${game.i18n.localize("CELESTOPOL.Roll.oppositionVs")}`
: `${game.i18n.localize("CELESTOPOL.Roll.threshold")} : 11`
return {
cssClass: [SYSTEM.id, "dice-roll"].join(" "),
@@ -394,11 +454,10 @@ export class CelestopolRoll extends Roll {
isSuccess: this.isSuccess,
isFailure: this.isFailure,
isTie: this.isTie,
isCriticalSuccess: this.isCriticalSuccess,
isCriticalFailure: this.isCriticalFailure,
isOpposition,
difficulty: this.options.difficulty,
difficultyLabel,
difficultyValue: threshold,
difficultyValue: isOpposition ? null : threshold,
margin,
marginAbs: margin !== null ? Math.abs(margin) : null,
marginAbove: margin !== null && margin >= 0,
@@ -419,6 +478,8 @@ export class CelestopolRoll extends Roll {
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,
// Dé de lune
hasMoonDie: moonDieResult !== null,
moonDieResult,
@@ -442,4 +503,44 @@ export class CelestopolRoll extends Roll {
: `<strong>${skillLocalized}</strong>`
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,
})
}
}