Files
fvtt-chroniques-de-l-etrange/src/ui/rolling.js
T
uberwald 20b41f2cd4
Release Creation / build (release) Successful in 1m4s
Nouvelle correction sur lancement des sorts
2026-06-10 20:34:41 +02:00

617 lines
24 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.
/**
* Chroniques de l'Étrange — Système FoundryVTT
*
* Chroniques de l'Étrange 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 20242026 LeRatierBretonnien
* @license CC BY-NC-SA 4.0 https://creativecommons.org/licenses/by-nc-sa/4.0/
*/
/**
* 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.
*/
import { MAGICS, ASPECT_LABELS, ASPECT_ICONS, ASPECT_FACES, ASPECT_NAMES, WU_XING_CYCLE } from "../config/constants.js"
import { updateLoksyuFromRoll, updateTinjiFromRoll } from "./apps/singletons.js"
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",
}
/** 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>}
*/
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.
*/
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,
rolldifficulty: params.rolldifficulty ?? 1,
freepowerlevels: params.freepowerlevels ?? 0,
typeofthrow: Number(params.typeofthrow ?? 0),
},
fields: ["aspectskill", "bonusmalusskill", "bonusauspiciousdice",
"rolldifficulty", "freepowerlevels", "typeofthrow"],
})
}
/**
* Open the weapon attack roll prompt and return user-confirmed parameters.
*/
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 ?? 0,
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,
flags: {
"fvtt-chroniques-de-l-etrange": { rollResult: { ...resultData } },
},
})
}
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 "aptitude":
// NPC aptitude roll — flat pool with WuXing prompt
numberofdice = sys.aptitudes?.[skillLibel]?.value ?? 0
title = game.i18n.localize(`CDE.${skillLibel.charAt(0).toUpperCase() + skillLibel.slice(1)}`)
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(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")}]`
const kfAspect = kfItem.system.aspect?.toLowerCase() ?? "metal"
const ASPECT_NORMALIZE = { eau: "water", terre: "earth", feu: "fire", bois: "wood" }
kfDefaultAspect = ASPECT_NAMES.indexOf(ASPECT_NORMALIZE[kfAspect] ?? kfAspect)
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 ?? 0,
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 ?? 0
const wpSpecialtyBonus = wpItem.system.hasSpeciality ? 1 : 0
const wpTotalDice = wpSkillDice + wpAspectDice + wpRangeMalus + wpBonusMalus - wpWoundMalus + wpSpecialtyBonus
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")}`)
// Damage = character's aspect value (from weapon's damageAspect) + weapon base damage
const wpDamageAspectRaw = wpItem.system.damageAspect ?? "metal"
const wpDamageAspectIdx = WEAPON_ASPECT_INDEX[wpDamageAspectRaw] ?? 0
const wpDamageAspectName = ASPECT_NAMES[wpDamageAspectIdx]
const wpDamageAspectValue = sys.aspect?.[wpDamageAspectName]?.value ?? 0
const wpDamageAspectLabel = game.i18n.localize(ASPECT_LABELS[wpDamageAspectName] ?? "")
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,
damageAspectValue: wpDamageAspectValue,
damageAspectLabel: wpDamageAspectLabel,
totalDamage: wpDamageBase + wpDamageAspectValue,
...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) {
try { await game.dice3d.waitFor3DAnimationByMessageID(wpMsg.id) } catch (_e) { /* DSN not available */ }
}
// Auto-update Loksyu/TinJi singletons from weapon roll faces
if ((wpResults.loksyudice ?? 0) > 0) await updateLoksyuFromRoll(wpAspectName, wpFaces)
if ((wpResults.tinjidice ?? 0) > 0) await updateTinjiFromRoll(wpResults.tinjidice)
return
}
default:
ui.notifications.warn(`Unknown roll type: ${typeLibel}`)
return
}
// For magic rolls / 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
}
// ---- Show roll prompt ----
let params
if (isMagic) {
params = await showMagicPrompt({
title,
numberofdice,
aspectskill: defaultAspect,
bonusmalusskill: 0,
bonusauspiciousdice: 0,
rolldifficulty: 1,
freepowerlevels: 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 rollDifficulty = 1 // magic only: multiplier applied to successes
if (isMagic) {
const skillAspectIndex = Number(params.aspectskill ?? 0)
aspectIndex = skillAspectIndex // used for both dice pool and Wu Xing cycle
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
numberofdice = numberofdice + aspectDice + bonusMalus + 1
} 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 spell power (magic only) ----
// Power = rollDifficulty × character aspect value for the speciality's
// associated element (or the school's aspect for base magic rolls).
let spellPower = null
let spellPowerAspectName = null
let spellPowerAspectValue = null
if (isMagic) {
if (isMagicSpecial && specialLibel) {
const specialCfg = MAGICS?.[skillLibel]?.speciality?.[specialLibel]
const elemName = LABELELEMENT_TO_ASPECT[specialCfg?.labelelementkey]
if (elemName) spellPowerAspectName = elemName
}
if (!spellPowerAspectName) spellPowerAspectName = ASPECT_NAMES[aspectIndex]
spellPowerAspectValue = sys.aspect?.[spellPowerAspectName]?.value ?? 0
const freePowerLevels = Number(params.freepowerlevels ?? 0)
spellPower = rollDifficulty * (spellPowerAspectValue + freePowerLevels)
}
// ---- Compute Wu Xing results ----
// The Wu Xing cycle always uses the roll's aspect (skill aspect for magic,
// skill/resource aspect otherwise) to determine which faces count as
// successes/auspicious/etc.
const wuXingAspectName = ASPECT_NAMES[aspectIndex]
const allResults = roll.dice[0]?.results ?? []
const faces = countFaces(allResults)
const results = computeWuXingResults(faces, wuXingAspectName, bonusAuspicious)
if (!results) return
// ---- Build modifier summary text ----
const modParts = []
if (isMagic) {
const bm = Number(params.bonusmalusskill ?? 0)
const ba = Number(params.bonusauspiciousdice ?? 0)
const fp = Number(params.freepowerlevels ?? 0)
if (bm !== 0) modParts.push(`${bm > 0 ? "+" : ""}${bm} ${game.i18n.localize("CDE.BonusMalus")}`)
if (ba !== 0) modParts.push(`+${ba} ${game.i18n.localize("CDE.BonusAuspiciousDice")}`)
if (fp !== 0) modParts.push(`+${fp} ${game.i18n.localize("CDE.FreePowerLevels")}`)
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,
spellPowerAspectLabel: spellPowerAspectName ? game.i18n.localize(ASPECT_LABELS[spellPowerAspectName] ?? "") : "",
spellPowerAspectValue,
spellPowerFreeLevels: isMagic ? Number(params.freepowerlevels ?? 0) : 0,
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) {
try { await game.dice3d.waitFor3DAnimationByMessageID(msg.id) } catch (_e) { /* DSN not available */ }
}
// ---- Auto-update Loksyu / TinJi singletons ----
if ((results.loksyudice ?? 0) > 0) await updateLoksyuFromRoll(wuXingAspectName, faces)
if ((results.tinjidice ?? 0) > 0) await updateTinjiFromRoll(results.tinjidice)
}