/** * 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 2024–2026 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|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} */ 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) }