Import initial du système

This commit is contained in:
2026-03-28 08:44:19 +01:00
parent 068fca00e5
commit f7a01900ac
105 changed files with 7362 additions and 2090 deletions
+621
View File
@@ -0,0 +1,621 @@
/**
* Wu Xing rolling system for Chroniques de l'Étrange.
*
* The Wu Xing cycle maps each aspect (by index 0-4) to die face groups:
* - metal=0 : faces 3,8
* - water=1 : faces 1,6
* - earth=2 : faces 0/10,5
* - fire=3 : faces 2,7
* - wood=4 : faces 4,9
*
* For a given active aspect the five result categories are:
* successes / auspicious / noxious / loksyu (yin face, yang face) / tinji
* Each category is associated with one of the five aspects in Wu Xing cycle order.
*/
const RESULT_TEMPLATE = "systems/fvtt-chroniques-de-l-etrange/templates/form/cde-dice-result.html"
const SKILL_PROMPT_TEMPLATE = "systems/fvtt-chroniques-de-l-etrange/templates/form/cde-skill-dice-prompt.html"
const SKILL_SPECIAL_PROMPT_TEMPLATE = "systems/fvtt-chroniques-de-l-etrange/templates/form/cde-skill-special-dice-prompt.html"
const MAGIC_PROMPT_TEMPLATE = "systems/fvtt-chroniques-de-l-etrange/templates/form/cde-magic-dice-prompt.html"
const WEAPON_PROMPT_TEMPLATE = "systems/fvtt-chroniques-de-l-etrange/templates/form/cde-weapon-dice-prompt.html"
/** Maps i18n element label → aspect name (for speciality default aspect lookup) */
const LABELELEMENT_TO_ASPECT = {
"CDE.Metal": "metal",
"CDE.Water": "water",
"CDE.Earth": "earth",
"CDE.Fire": "fire",
"CDE.Wood": "wood",
}
/** Map aspect index → string name used in result template */
const ASPECT_NAMES = ["metal", "water", "earth", "fire", "wood"]
/** Map aspect name → i18n label key */
const ASPECT_LABELS = {
metal: "CDE.Metal",
water: "CDE.Water",
earth: "CDE.Earth",
fire: "CDE.Fire",
wood: "CDE.Wood",
}
/** Map aspect name → image path */
const ASPECT_ICONS = {
metal: "systems/fvtt-chroniques-de-l-etrange/images/cde_metal.png",
water: "systems/fvtt-chroniques-de-l-etrange/images/cde_eau.png",
earth: "systems/fvtt-chroniques-de-l-etrange/images/cde_terre.png",
fire: "systems/fvtt-chroniques-de-l-etrange/images/cde_feu.png",
wood: "systems/fvtt-chroniques-de-l-etrange/images/cde_bois.png",
}
/** Map aspect index → die face pair [yin, yang] (face=10 stored as 0) */
const ASPECT_FACES = {
metal: [3, 8],
water: [1, 6],
earth: [0, 5], // 0 = face "10"
fire: [2, 7],
wood: [4, 9],
}
/**
* Wu Xing generating/overcoming cycle:
* wood → fire → earth → metal → water → wood (generating)
* For each active aspect, the five categories in order:
* [successes, auspicious, noxious, loksyu, tinji]
*/
const WU_XING_CYCLE = {
wood: ["wood", "fire", "water", "earth", "metal"],
fire: ["fire", "earth", "wood", "metal", "water"],
earth: ["earth", "metal", "fire", "water", "wood"],
metal: ["metal", "water", "earth", "wood", "fire"],
water: ["water", "wood", "metal", "fire", "earth"],
}
/** Maps weapon range string → dice malus applied to the attack pool */
const RANGE_MALUS = {
contact: 0,
courte: 0,
mediane: -1,
longue: -2,
extreme: -3,
}
/** Maps weapon type string → default skill key */
const WEAPON_TYPE_SKILL = {
melee: "kungfu",
thrown: "rangedcombat",
ranged: "rangedcombat",
firearm: "rangedcombat",
}
/** Maps weapon damageAspect name → ASPECT_NAMES index */
const WEAPON_ASPECT_INDEX = { metal: 0, eau: 1, water: 1, terre: 2, earth: 2, feu: 3, fire: 3, bois: 4, wood: 4 }
/** Count how many times each die face appeared in the roll results */
function countFaces(rollResults) {
const counts = { 1:0, 2:0, 3:0, 4:0, 5:0, 6:0, 7:0, 8:0, 9:0, 0:0 }
for (const die of rollResults) {
const face = die.result === 10 ? 0 : die.result
counts[face]++
}
return counts
}
/**
* Compute Wu Xing result categories from face counts and active aspect.
* Returns { successesdice, auspiciousdice, noxiousdice, loksyudice, tinjidice, loksyurepartition }
*/
function computeWuXingResults(faces, aspectName, bonusAuspicious = 0) {
const cycle = WU_XING_CYCLE[aspectName]
if (!cycle) return null
const [succAspect, ausAspect, noxAspect, lokAspect, tinAspect] = cycle
const [succYin, succYang] = ASPECT_FACES[succAspect]
const [ausYin, ausYang] = ASPECT_FACES[ausAspect]
const [noxYin, noxYang] = ASPECT_FACES[noxAspect]
const [lokYin, lokYang] = ASPECT_FACES[lokAspect]
const [tinYin, tinYang] = ASPECT_FACES[tinAspect]
const yin = game.i18n.localize("CDE.Yin")
const yang = game.i18n.localize("CDE.Yang")
return {
successesdice: faces[succYin] + faces[succYang],
auspiciousdice: faces[ausYin] + faces[ausYang] + bonusAuspicious,
noxiousdice: faces[noxYin] + faces[noxYang],
loksyudice: faces[lokYin] + faces[lokYang],
loksyurepartition: `[${yin}(${faces[lokYin]}) ${yang}(${faces[lokYang]})]`,
tinjidice: faces[tinYin] + faces[tinYang],
}
}
/** Read a named field from a dialog DOM element */
function readField(dlg, name) {
const el = dlg.querySelector(`[name="${name}"]`)
if (!el) return null
return el.type === "checkbox" ? el.checked : el.value
}
/**
* Open a DialogV2.prompt with the given template + data and return the resolved form values.
* The callback receives the DialogV2 application instance; fields are read from its .element.
* @returns {Promise<Record<string,string>|null>}
*/
async function showRollPrompt({ title, template, data, fields }) {
const content = await foundry.applications.handlebars.renderTemplate(template, data)
return foundry.applications.api.DialogV2.prompt({
window: { title },
content,
rejectClose: false,
ok: {
label: game.i18n.localize("CDE.Validate"),
callback: (event, button, dialog) => {
// In AppV2, dialog is the application instance; .element is the root HTMLElement
const root = dialog.element ?? dialog
const result = {}
for (const field of fields) {
result[field] = readField(root, field)
}
return result
},
},
})
}
/**
* Open the skill roll prompt and return the user-confirmed parameters.
* @param {object} params - Initial values
* @returns {Promise<object|null>}
*/
export async function showSkillPrompt(params) {
return showRollPrompt({
title: params.title,
template: params.isSpecial ? SKILL_SPECIAL_PROMPT_TEMPLATE : SKILL_PROMPT_TEMPLATE,
data: {
numberofdice: params.numberofdice,
aspect: Number(params.aspect ?? 0),
bonusmalus: params.bonusmalus ?? 0,
woundmalus: params.woundmalus ?? 0,
bonusauspiciousdice: params.bonusauspiciousdice ?? 0,
typeofthrow: Number(params.typeofthrow ?? 0),
},
fields: ["aspect", "bonusmalus", "woundmalus", "bonusauspiciousdice", "typeofthrow"],
})
}
/**
* Open the magic roll prompt and return the user-confirmed parameters.
*/
export async function showMagicPrompt(params) {
return showRollPrompt({
title: params.title,
template: MAGIC_PROMPT_TEMPLATE,
data: {
numberofdice: params.numberofdice ?? 0,
aspectskill: Number(params.aspectskill ?? 0),
bonusmalusskill: params.bonusmalusskill ?? 0,
bonusauspiciousdice: params.bonusauspiciousdice ?? 0,
aspectspeciality: Number(params.aspectspeciality ?? 0),
rolldifficulty: params.rolldifficulty ?? 1,
bonusmalusspeciality: params.bonusmalusspeciality ?? 0,
heispend: params.heispend ?? 0,
typeofthrow: Number(params.typeofthrow ?? 0),
},
fields: ["aspectskill", "bonusmalusskill", "bonusauspiciousdice",
"aspectspeciality", "rolldifficulty", "bonusmalusspeciality",
"heispend", "typeofthrow"],
})
}
/**
* Open the weapon attack roll prompt and return user-confirmed parameters.
*/
export async function showWeaponPrompt(params) {
return showRollPrompt({
title: params.title,
template: WEAPON_PROMPT_TEMPLATE,
data: {
numberofdice: params.numberofdice ?? 0,
weaponName: params.weaponName ?? "",
weaponTypeLabel: params.weaponTypeLabel ?? "CDE.Weapon",
weaponAspectIcon: params.weaponAspectIcon ?? "",
weaponAspectLabel: params.weaponAspectLabel ?? "",
damageBase: params.damageBase ?? 1,
weaponskill: params.weaponskill ?? "kungfu",
aspect: Number(params.aspect ?? 0),
effectiverange: params.effectiverange ?? "contact",
bonusmalus: params.bonusmalus ?? 0,
woundmalus: params.woundmalus ?? 0,
bonusauspiciousdice: params.bonusauspiciousdice ?? 0,
typeofthrow: Number(params.typeofthrow ?? 0),
},
fields: ["weaponskill", "aspect", "effectiverange", "bonusmalus", "woundmalus",
"bonusauspiciousdice", "typeofthrow"],
})
}
/**
* Build and send a single enriched ChatMessage containing both the roll
* (for Dice So Nice) and the Wu Xing result card.
*/
async function sendResultMessage(actor, resultData, roll, rollMode) {
const html = await foundry.applications.handlebars.renderTemplate(RESULT_TEMPLATE, resultData)
return ChatMessage.create({
user: game.user.id,
speaker: ChatMessage.getSpeaker({ actor }),
content: html,
rolls: [roll],
rollMode,
})
}
const ROLL_MODES = ["roll", "gmroll", "blindroll", "selfroll"]
/**
* Main entry point: roll dice for a given actor.
*
* @param {Actor} actor
* @param {string} rollKey - e.g. "prowess-skill", "fire-aspect", "alchemy-magic-elixirs"
*/
export async function rollForActor(actor, rollKey) {
const parts = rollKey.split("-")
const skillLibel = parts[0]
const typeLibel = parts[1]
const specialLibel = parts[2] ?? null
const sys = actor.system
const typeOfThrow = Number(sys.prefs?.typeofthrow?.choice ?? 0)
let numberofdice = 0
let title = ""
let isSpecial = false
let isMagic = false
let isMagicSpecial = false
let kfDefaultAspect = -1 // set in "itemkungfu" case, used when computing defaultAspect
// ---- Determine dice count + title ----
const MAGIC_I18N_KEYS = {
internalcinnabar: "CDE.InternalCinnabar",
alchemy: "CDE.Alchemy",
masteryoftheway: "CDE.MasteryOfTheWay",
exorcism: "CDE.Exorcism",
geomancy: "CDE.Geomancy",
}
switch (typeLibel) {
case "aspect":
numberofdice = sys.aspect[skillLibel]?.value ?? 0
title = game.i18n.localize(sys.aspect[skillLibel]?.label ?? "CDE.Roll")
break
case "skill":
numberofdice = sys.skills[skillLibel]?.value ?? 0
title = game.i18n.localize(sys.skills[skillLibel]?.label ?? "CDE.Roll")
break
case "special":
numberofdice = sys.skills[skillLibel]?.value ?? 0
title = game.i18n.localize(sys.skills[skillLibel]?.label ?? "CDE.Roll")
title += ` [${game.i18n.localize("CDE.Speciality")}]`
isSpecial = true
if (!sys.skills[skillLibel]?.specialities) {
ui.notifications.warn(game.i18n.localize("CDE.Error2"))
return
}
break
case "resource":
numberofdice = sys.resources[skillLibel]?.value ?? 0
title = game.i18n.localize(sys.resources[skillLibel]?.label ?? "CDE.Roll")
break
case "field":
numberofdice = sys.resources[skillLibel]?.value ?? 0
title = game.i18n.localize(sys.resources[skillLibel]?.label ?? "CDE.Roll")
title += ` [${game.i18n.localize("CDE.Field")}]`
isSpecial = true
if (!sys.resources[skillLibel]?.specialities) {
ui.notifications.warn(game.i18n.localize("CDE.Error4"))
return
}
break
case "magic":
numberofdice = sys.magics[skillLibel]?.value ?? 0
isMagic = true
title = game.i18n.localize(MAGIC_I18N_KEYS[skillLibel] ?? "CDE.Magics")
break
case "magicspecial":
numberofdice = sys.magics[skillLibel]?.value ?? 0
isMagicSpecial = true
isMagic = true
if (!sys.magics[skillLibel]?.speciality?.[specialLibel]?.check) {
ui.notifications.warn(game.i18n.localize("CDE.Error6"))
return
}
title = `${game.i18n.localize(MAGIC_I18N_KEYS[skillLibel] ?? "CDE.Magics")} [${game.i18n.localize(game.system.CONST?.MAGICS?.[skillLibel]?.speciality?.[specialLibel]?.label ?? "")}]`
break
case "itemkungfu": {
// skillLibel = item._id — look up the kungfu item to find which skill + aspect to use
const kfItem = actor.items.get(skillLibel)
if (!kfItem) { ui.notifications.warn(game.i18n.localize("CDE.Error0")); return }
const kfSkill = kfItem.system.skill ?? "kungfu"
numberofdice = sys.skills[kfSkill]?.value ?? 0
title = `${kfItem.name} [${game.i18n.localize(sys.skills[kfSkill]?.label ?? "CDE.KungFu")}]`
kfDefaultAspect = ASPECT_NAMES.indexOf(kfItem.system.aspect ?? "metal")
if (kfDefaultAspect < 0) kfDefaultAspect = 0
break
}
case "itemweapon": {
// skillLibel = item._id — look up the weapon item to find type + aspect + damage
const wpItem = actor.items.get(skillLibel)
if (!wpItem) { ui.notifications.warn(game.i18n.localize("CDE.Error0")); return }
const wpType = wpItem.system.weaponType ?? "melee"
const wpSkill = WEAPON_TYPE_SKILL[wpType] ?? "kungfu"
numberofdice = sys.skills[wpSkill]?.value ?? 0
const wpAspectRaw = wpItem.system.damageAspect ?? "metal"
const wpAspectIdx = WEAPON_ASPECT_INDEX[wpAspectRaw] ?? 0
const wpRange = wpItem.system.range ?? "contact"
const WEAPON_TYPE_LABELS = {
melee: "CDE.WeaponMelee",
thrown: "CDE.WeaponThrown",
ranged: "CDE.WeaponRanged",
firearm: "CDE.WeaponFirearm",
}
// Show weapon-specific prompt
const wParams = await showWeaponPrompt({
title: `${wpItem.name} [${game.i18n.localize(sys.skills[wpSkill]?.label ?? "CDE.WeaponRoll")}]`,
numberofdice,
weaponName: wpItem.name,
weaponTypeLabel: WEAPON_TYPE_LABELS[wpType] ?? "CDE.Weapon",
weaponAspectIcon: ASPECT_ICONS[ASPECT_NAMES[wpAspectIdx]] ?? "",
weaponAspectLabel: game.i18n.localize(ASPECT_LABELS[ASPECT_NAMES[wpAspectIdx]] ?? ""),
damageBase: wpItem.system.damageBase ?? 1,
weaponskill: wpSkill,
aspect: wpAspectIdx,
effectiverange: wpRange,
bonusmalus: 0,
woundmalus: 0,
bonusauspiciousdice: 0,
typeofthrow: typeOfThrow,
})
if (!wParams) return // cancelled
// Resolve final pool from weapon prompt values
const wpChosenSkill = wParams.weaponskill ?? wpSkill
const wpSkillDice = sys.skills[wpChosenSkill]?.value ?? 0
const wpAspFinal = Number(wParams.aspect ?? wpAspectIdx)
const wpAspectDice = sys.aspect[ASPECT_NAMES[wpAspFinal]]?.value ?? 0
const wpRangeMalus = RANGE_MALUS[wParams.effectiverange ?? "contact"] ?? 0
const wpBonusMalus = Number(wParams.bonusmalus ?? 0)
const wpWoundMalus = Number(wParams.woundmalus ?? 0)
const wpBonusAusp = Number(wParams.bonusauspiciousdice ?? 0)
const wpThrowMode = Number(wParams.typeofthrow ?? 0)
const wpDamageBase = wpItem.system.damageBase ?? 1
const wpTotalDice = wpSkillDice + wpAspectDice + wpRangeMalus + wpBonusMalus - wpWoundMalus
if (wpTotalDice <= 0) {
ui.notifications.warn(game.i18n.localize("CDE.Error0"))
return
}
const wpRoll = new Roll(`${wpTotalDice}d10`)
await wpRoll.evaluate()
const wpAspectName = ASPECT_NAMES[wpAspFinal] ?? "metal"
const wpFaces = countFaces(wpRoll.dice[0]?.results ?? [])
const wpResults = computeWuXingResults(wpFaces, wpAspectName, wpBonusAusp)
if (!wpResults) return
const wpModParts = []
if (wpRangeMalus !== 0) wpModParts.push(`${wpRangeMalus} ${game.i18n.localize("CDE.RangePenalty")}`)
if (wpBonusMalus !== 0) wpModParts.push(`${wpBonusMalus > 0 ? "+" : ""}${wpBonusMalus} ${game.i18n.localize("CDE.BonusMalus")}`)
if (wpWoundMalus !== 0) wpModParts.push(`-${wpWoundMalus} ${game.i18n.localize("CDE.WoundMalus")}`)
if (wpBonusAusp !== 0) wpModParts.push(`+${wpBonusAusp} ${game.i18n.localize("CDE.BonusAuspiciousDice")}`)
const wpMsg = await sendResultMessage(actor, {
rollLabel: `${wpItem.name}`,
aspectName: wpAspectName,
aspectLabel: game.i18n.localize(ASPECT_LABELS[wpAspectName] ?? ""),
aspectIcon: ASPECT_ICONS[wpAspectName] ?? "",
totalDice: wpTotalDice,
modifiersText: wpModParts.length ? wpModParts.join(" · ") : "",
spellPower: null,
rollDifficulty: null,
actorName: actor.name ?? "",
actorImg: actor.img ?? "",
// weapon-specific
weaponName: wpItem.name,
damageBase: wpDamageBase,
totalDamage: wpResults.successesdice * wpDamageBase,
...wpResults,
aspect: wpAspectName,
d1: wpFaces[1], d2: wpFaces[2], d3: wpFaces[3], d4: wpFaces[4], d5: wpFaces[5],
d6: wpFaces[6], d7: wpFaces[7], d8: wpFaces[8], d9: wpFaces[9], d0: wpFaces[0],
}, wpRoll, ROLL_MODES[wpThrowMode] ?? "roll")
if (game.modules.get("dice-so-nice")?.active && wpMsg?.id) {
await game.dice3d.waitFor3DAnimationByMessageID(wpMsg.id)
}
return
}
default:
ui.notifications.warn(`Unknown roll type: ${typeLibel}`)
return
}
// For magic rolls the prompt allows adding HEI dice, so don't block early.
// For itemkungfu, allow 0 base dice (user can add bonus dice in the prompt).
if (numberofdice <= 0 && typeLibel !== "aspect" && typeLibel !== "itemkungfu" && !isMagic) {
ui.notifications.warn(game.i18n.localize("CDE.Error0"))
return
}
// ---- Pre-compute default aspect for magic based on skill ----
const MAGIC_ASPECTS = {
internalcinnabar: 0, // metal
alchemy: 1, // water
masteryoftheway: 2, // earth
exorcism: 3, // fire
geomancy: 4, // wood
}
let defaultAspect = typeLibel === "aspect"
? ["metal","water","earth","fire","wood"].indexOf(skillLibel)
: 0
if (isMagic && MAGIC_ASPECTS[skillLibel] !== undefined) {
defaultAspect = MAGIC_ASPECTS[skillLibel]
}
if (kfDefaultAspect >= 0) {
defaultAspect = kfDefaultAspect
}
let defaultSpecialAspect = 0
if (isMagicSpecial && specialLibel) {
// Look up the speciality's element from the MAGICS config constant
const specialCfg = game.system.CONST?.MAGICS?.[skillLibel]?.speciality?.[specialLibel]
const aspectName = LABELELEMENT_TO_ASPECT[specialCfg?.labelelement]
if (aspectName) {
defaultSpecialAspect = ASPECT_NAMES.indexOf(aspectName)
}
}
// ---- Show roll prompt ----
let params
if (isMagic) {
params = await showMagicPrompt({
title,
numberofdice,
aspectskill: defaultAspect,
bonusmalusskill: 0,
bonusauspiciousdice: 0,
aspectspeciality: defaultSpecialAspect,
rolldifficulty: 1,
bonusmalusspeciality: 0,
heispend: 0,
typeofthrow: typeOfThrow,
})
} else {
params = await showSkillPrompt({
title,
numberofdice,
aspect: defaultAspect,
bonusmalus: 0,
woundmalus: 0,
bonusauspiciousdice: 0,
typeofthrow: typeOfThrow,
isSpecial,
})
}
if (!params) return // cancelled
// ---- Compute total dice and roll ----
let aspectIndex, bonusMalus, bonusAuspicious, throwMode
let spellAspectIndex = null // magic only: aspect of the speciality for Wu Xing
let rollDifficulty = 1 // magic only: multiplier applied to successes
if (isMagic) {
const skillAspectIndex = Number(params.aspectskill ?? 0)
spellAspectIndex = Number(params.aspectspeciality ?? skillAspectIndex)
aspectIndex = skillAspectIndex // used only for skill dice pool
bonusMalus = Number(params.bonusmalusskill ?? 0)
bonusAuspicious = Number(params.bonusauspiciousdice ?? 0)
rollDifficulty = Math.max(1, Number(params.rolldifficulty ?? 1))
throwMode = Number(params.typeofthrow ?? 0)
// magic: magic skill + aspect + bonuses + 1 (speciality base) + HEI spent
const aspectDice = sys.aspect[ASPECT_NAMES[aspectIndex]]?.value ?? 0
const bonusSpec = Number(params.bonusmalusspeciality ?? 0)
const heiDice = Number(params.heispend ?? 0)
numberofdice = numberofdice + aspectDice + bonusMalus + 1 + bonusSpec + heiDice
} else {
aspectIndex = Number(params.aspect ?? 0)
bonusMalus = Number(params.bonusmalus ?? 0)
const woundMalus = Number(params.woundmalus ?? 0)
bonusAuspicious = Number(params.bonusauspiciousdice ?? 0)
throwMode = Number(params.typeofthrow ?? 0)
const aspectDice = (typeLibel !== "aspect")
? (sys.aspect[ASPECT_NAMES[aspectIndex]]?.value ?? 0)
: 0
numberofdice = numberofdice + aspectDice + bonusMalus - woundMalus
if (isSpecial) numberofdice += 1 // +1d for speciality
}
if (numberofdice <= 0) {
ui.notifications.warn(game.i18n.localize("CDE.Error0"))
return
}
// ---- Roll ----
const roll = new Roll(`${numberofdice}d10`)
await roll.evaluate()
const rollModeKey = ROLL_MODES[throwMode] ?? "roll"
// ---- Compute Wu Xing results ----
// For magic rolls, the spell's aspect (aspectspeciality) governs the Wu Xing
// cycle (which faces count as successes/auspicious/etc.), not the skill aspect.
const wuXingAspectName = spellAspectIndex !== null
? ASPECT_NAMES[spellAspectIndex]
: ASPECT_NAMES[aspectIndex]
const allResults = roll.dice[0]?.results ?? []
const faces = countFaces(allResults)
const results = computeWuXingResults(faces, wuXingAspectName, bonusAuspicious)
if (!results) return
// For magic, successesdice × rollDifficulty = spell power
const spellPower = isMagic ? results.successesdice * rollDifficulty : null
// ---- Build modifier summary text ----
const modParts = []
if (isMagic) {
const bm = Number(params.bonusmalusskill ?? 0)
const bs = Number(params.bonusmalusspeciality ?? 0)
const hs = Number(params.heispend ?? 0)
const ba = Number(params.bonusauspiciousdice ?? 0)
if (bm !== 0) modParts.push(`${bm > 0 ? "+" : ""}${bm} ${game.i18n.localize("CDE.BonusMalus")}`)
if (bs !== 0) modParts.push(`${bs > 0 ? "+" : ""}${bs} ${game.i18n.localize("CDE.SpellBonus")}`)
if (ba !== 0) modParts.push(`+${ba} ${game.i18n.localize("CDE.BonusAuspiciousDice")}`)
if (hs !== 0) modParts.push(`${hs} ${game.i18n.localize("CDE.HeiSpend")}`)
if (rollDifficulty !== 1) modParts.push(`×${rollDifficulty} ${game.i18n.localize("CDE.RollDifficulty")}`)
} else {
const bm = Number(params.bonusmalus ?? 0)
const wm = Number(params.woundmalus ?? 0)
const ba = Number(params.bonusauspiciousdice ?? 0)
if (bm !== 0) modParts.push(`${bm > 0 ? "+" : ""}${bm} ${game.i18n.localize("CDE.BonusMalus")}`)
if (wm !== 0) modParts.push(`-${wm} ${game.i18n.localize("CDE.WoundMalus")}`)
if (ba !== 0) modParts.push(`+${ba} ${game.i18n.localize("CDE.BonusAuspiciousDice")}`)
}
// ---- Send single enriched ChatMessage (roll + result card) ----
const msg = await sendResultMessage(actor, {
// Roll identity
rollLabel: title,
aspectName: wuXingAspectName,
aspectLabel: game.i18n.localize(ASPECT_LABELS[wuXingAspectName] ?? ""),
aspectIcon: ASPECT_ICONS[wuXingAspectName] ?? "",
totalDice: numberofdice,
modifiersText: modParts.length ? modParts.join(" · ") : "",
// Spell power (magic only)
spellPower,
rollDifficulty: isMagic ? rollDifficulty : null,
// Actor info
actorName: actor.name ?? "",
actorImg: actor.img ?? "",
// Wu Xing results
aspect: wuXingAspectName,
...results,
// Die face counts
d1: faces[1], d2: faces[2], d3: faces[3], d4: faces[4], d5: faces[5],
d6: faces[6], d7: faces[7], d8: faces[8], d9: faces[9], d0: faces[0],
}, roll, rollModeKey)
// ---- Wait for Dice So Nice animation ----
if (game.modules.get("dice-so-nice")?.active && msg?.id) {
await game.dice3d.waitFor3DAnimationByMessageID(msg.id)
}
}