617 lines
24 KiB
JavaScript
617 lines
24 KiB
JavaScript
/**
|
||
* 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<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)
|
||
}
|