import { SYSTEM } from "./config/system.mjs"
/**
* Perform an Oath Hammer skill check and post results to chat.
*
* Dice rules (p.38-40):
* - Pool = Attribute rank + Skill rank + per-skill modifier + bonus + (luckSpend ร 2) + supporters
* - White dice succeed on 4+ | Red: 3+ | Black: 2+
* - All dice explode on 6 (roll extra die, continues while rolling 6s)
* - Pool can never drop below 1 die (penalties rule)
* - Luck Points: 1 LP spent = +2 dice; LP are deducted from actor.system.luck.value
* - Supporters: each ally with ranks in the skill adds +1 die
*
* @param {Actor} actor The actor performing the check
* @param {string} skillKey Skill key (e.g. "fortune")
* @param {number} dv Difficulty Value (successes required)
* @param {object} [options]
* @param {number} [options.bonus] Extra dice from dialog modifier (can be negative)
* @param {number} [options.luckSpend] Luck Points to spend (each adds +2 dice)
* @param {number} [options.supporters] Allies supporting the check (each adds +1 die)
* @param {string} [options.attrOverride] Override governing attribute (for dual-attr skills)
* @param {string} [options.visibility] Roll mode (public/gmroll/blindroll/selfroll)
* @param {string} [options.flavor] Optional flavor text for the chat card
* @returns {Promise<{successes: number, dv: number, isSuccess: boolean}>}
*/
export async function rollSkillCheck(actor, skillKey, dv, options = {}) {
const { bonus = 0, luckSpend = 0, luckIsHuman = false, supporters = 0, attrOverride, visibility, flavor, explodeOn5 = false } = options
const sys = actor.system
const skillDef = SYSTEM.SKILLS[skillKey]
if (!skillDef) throw new Error(`Unknown skill: ${skillKey}`)
// Attribute โ use override if provided (dual-attribute skills: Defense, Fighting, Magic)
const attrKey = attrOverride && sys.attributes[attrOverride] ? attrOverride : skillDef.attribute
const attrRank = sys.attributes[attrKey].rank
const skill = sys.skills[skillKey]
const skillRank = skill.rank
const skillMod = skill.modifier ?? 0
const colorType = skill.colorDiceType ?? "white"
const threshold = colorType === "black" ? 2 : colorType === "red" ? 3 : 4
const luckDicePerPoint = luckIsHuman ? 3 : 2
// Total dice pool (never below 1)
const totalDice = Math.max(attrRank + skillRank + skillMod + bonus + (luckSpend * luckDicePerPoint) + supporters, 1)
// Deduct spent Luck Points from actor
if (luckSpend > 0) {
const currentLuck = sys.luck?.value ?? 0
await actor.update({ "system.luck.value": Math.max(0, currentLuck - luckSpend) })
}
// Roll the dice pool
const roll = await new Roll(`${totalDice}d6`).evaluate()
const allRolls = [roll]
// Count successes โ exploding dice produce additional dice
const explodeThreshold = explodeOn5 ? 5 : 6 // default: always explode on 6
let successes = 0
const diceResults = []
let extraDice = 0
for (const r of roll.dice[0].results) {
const val = r.result
if (val >= threshold) successes++
if (val >= explodeThreshold) extraDice++
diceResults.push({ val, exploded: false })
}
while (extraDice > 0) {
const xRoll = await new Roll(`${extraDice}d6`).evaluate()
allRolls.push(xRoll)
extraDice = 0
for (const r of xRoll.dice[0].results) {
const val = r.result
if (val >= threshold) successes++
if (val >= explodeThreshold) extraDice++
diceResults.push({ val, exploded: true })
}
}
const isOpposed = dv === 0
const isSuccess = isOpposed ? null : successes >= dv
const colorEmoji = colorType === "black" ? "โฌ" : colorType === "red" ? "๐ด" : "โฌ"
const skillLabel = game.i18n.localize(skillDef.label)
const attrLabel = game.i18n.localize(`OATHHAMMER.Attribute.${attrKey.charAt(0).toUpperCase()}${attrKey.slice(1)}`)
// Build dice display HTML
const diceHtml = diceResults.map(({ val, exploded }) => {
const success = val >= threshold
const cssClass = success ? "die-success" : "die-fail"
const explodedClass = exploded ? " die-exploded" : ""
return `${val} `
}).join(" ")
// Build modifier summary
const explodedCount = diceResults.filter(d => d.exploded).length
const modParts = []
if (bonus !== 0) modParts.push(`${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}`)
if (luckSpend > 0) modParts.push(`+${luckSpend * luckDicePerPoint} ${game.i18n.localize("OATHHAMMER.Dialog.LuckSpend")} (${luckSpend}LP${luckIsHuman ? " ๐ค" : ""})`)
if (supporters > 0) modParts.push(`+${supporters} ${game.i18n.localize("OATHHAMMER.Dialog.Supporters")}`)
if (explodeOn5) modParts.push(`๐ฅ ${game.i18n.localize("OATHHAMMER.Dialog.ExplodeOn5")}`)
if (explodedCount > 0) modParts.push(`๐ฅ ${explodedCount} ${game.i18n.localize("OATHHAMMER.Roll.Exploded")}`)
const modLine = modParts.length ? `
${modParts.join(" ยท ")}
` : ""
const resultClass = isOpposed ? "roll-opposed" : isSuccess ? "roll-success" : "roll-failure"
const resultLabel = isOpposed
? game.i18n.localize("OATHHAMMER.Roll.Opposed")
: isSuccess
? game.i18n.localize("OATHHAMMER.Roll.Success")
: game.i18n.localize("OATHHAMMER.Roll.Failure")
const cardFlavor = flavor ?? (isOpposed
? `${skillLabel} ${game.i18n.localize("OATHHAMMER.Roll.Check")} (${game.i18n.localize("OATHHAMMER.Roll.Opposed")})`
: `${skillLabel} ${game.i18n.localize("OATHHAMMER.Roll.Check")} (DV ${dv})`)
const successDisplay = isOpposed ? String(successes) : `${successes} / ${dv}`
const content = `
${attrLabel} ${attrRank} + ${skillLabel} ${skillRank}
${colorEmoji} ${totalDice}d6 (${threshold}+)
${modLine}
${diceHtml}
${successDisplay}
${resultLabel}
`
const rollMode = visibility ?? game.settings.get("core", "rollMode")
const msgData = {
speaker: ChatMessage.getSpeaker({ actor }),
content,
rolls: allRolls,
sound: CONFIG.sounds.dice,
}
ChatMessage.applyRollMode(msgData, rollMode)
await ChatMessage.create(msgData)
return { successes, dv, isSuccess }
}
/**
* Roll a Fortune check to find an item of a given rarity.
* Used by the rollable rarity button on item sheets.
* @param {Actor} actor The actor making the check
* @param {string} rarityKey Rarity key (e.g. "rare", "very-rare")
* @param {string} [itemName] Optional item name for flavor text
*/
export async function rollRarityCheck(actor, rarityKey, itemName) {
const dv = SYSTEM.RARITY_DV[rarityKey] ?? 1
if (rarityKey === "always") {
const rarityLabel = game.i18n.localize(SYSTEM.RARITY_CHOICES[rarityKey])
const content = `
${rarityLabel} โ ${game.i18n.localize("OATHHAMMER.Roll.AutoSuccess")}
`
await ChatMessage.create({ speaker: ChatMessage.getSpeaker({ actor }), content })
return { successes: 0, dv: 0, isSuccess: true }
}
const rarityLabel = game.i18n.localize(SYSTEM.RARITY_CHOICES[rarityKey])
const flavor = `${game.i18n.localize("OATHHAMMER.Skill.Fortune")} โ ${itemName ?? rarityLabel} (DV ${dv})`
return rollSkillCheck(actor, "fortune", dv, { flavor })
}
// ============================================================
// SHARED DICE HELPER
// ============================================================
/**
* Roll a pool of dice, counting successes (including exploding 6s).
* @param {number} pool Number of dice to roll
* @param {number} threshold Minimum value to count as a success
* @returns {Promise<{roll: Roll, successes: number, diceResults: Array}>}
*/
export async function _rollPool(pool, threshold, explodeOn5 = false) {
const explodeThreshold = explodeOn5 ? 5 : 6 // default: always explode on 6
const roll = await new Roll(`${Math.max(pool, 1)}d6`).evaluate()
const rolls = [roll]
let successes = 0
const diceResults = []
let extraDice = 0
for (const r of roll.dice[0].results) {
const val = r.result
if (val >= threshold) successes++
if (val >= explodeThreshold) extraDice++
diceResults.push({ val, exploded: false })
}
while (extraDice > 0) {
const xRoll = await new Roll(`${extraDice}d6`).evaluate()
rolls.push(xRoll)
extraDice = 0
for (const r of xRoll.dice[0].results) {
const val = r.result
if (val >= threshold) successes++
if (val >= explodeThreshold) extraDice++
diceResults.push({ val, exploded: true })
}
}
return { roll, rolls, successes, diceResults }
}
/**
* Render dice results as HTML spans.
*/
export function _diceHtml(diceResults, threshold) {
return diceResults.map(({ val, exploded }) => {
const cssClass = val >= threshold ? "die-success" : "die-fail"
return `${val} `
}).join(" ")
}
// ============================================================
// WEAPON ATTACK ROLL
// ============================================================
/**
* Roll a weapon attack and post the result to chat.
* The chat card includes a "Roll Damage" button that triggers rollWeaponDamage.
*
* @param {Actor} actor The attacking actor
* @param {Item} weapon The weapon item
* @param {object} options From OathHammerWeaponDialog.promptAttack()
*/
export async function rollWeaponAttack(actor, weapon, options = {}) {
const { attackBonus = 0, rangeCondition = 0, attrOverride, colorOverride, visibility, autoAttackBonus = 0, explodeOn5 = false, luckSpend = 0, luckIsHuman = false } = options
const sys = weapon.system
const actorSys = actor.system
const isRanged = sys.proficiencyGroup === "bows" || sys.proficiencyGroup === "throwing"
const skillKey = (sys.skillOverride && SYSTEM.SKILLS[sys.skillOverride]) ? sys.skillOverride : (isRanged ? "shooting" : "fighting")
const skillDef = SYSTEM.SKILLS[skillKey]
const defaultAttr = skillDef.attribute
const attrKey = attrOverride && actorSys.attributes[attrOverride] ? attrOverride : defaultAttr
const attrRank = actorSys.attributes[attrKey].rank
const skillRank = actorSys.skills[skillKey].rank
const baseColorType = actorSys.skills[skillKey].colorDiceType ?? "white"
const colorType = colorOverride || baseColorType
const threshold = colorType === "black" ? 2 : colorType === "red" ? 3 : 4
const colorEmoji = colorType === "black" ? "โฌ" : colorType === "red" ? "๐ด" : "โฌ"
const luckDicePerPoint = luckIsHuman ? 3 : 2
const totalDice = Math.max(attrRank + skillRank + attackBonus + rangeCondition + autoAttackBonus + (luckSpend * luckDicePerPoint), 1)
if (luckSpend > 0) {
const currentLuck = actorSys.luck?.value ?? 0
await actor.update({ "system.luck.value": Math.max(0, currentLuck - luckSpend) })
}
const { roll, rolls, successes, diceResults } = await _rollPool(totalDice, threshold, explodeOn5)
const skillLabel = game.i18n.localize(skillDef.label)
const attrLabel = game.i18n.localize(`OATHHAMMER.Attribute.${attrKey.charAt(0).toUpperCase() + attrKey.slice(1)}`)
const diceHtml = _diceHtml(diceResults, threshold)
const modParts = []
if (attackBonus !== 0) modParts.push(`${attackBonus > 0 ? "+" : ""}${attackBonus} ${game.i18n.localize("OATHHAMMER.Dialog.AttackModifier")}`)
if (rangeCondition !== 0) modParts.push(`${rangeCondition} ${game.i18n.localize("OATHHAMMER.Dialog.RangeCondition")}`)
if (autoAttackBonus > 0) modParts.push(`+${autoAttackBonus} auto`)
if (luckSpend > 0) modParts.push(`+${luckSpend * luckDicePerPoint} ${game.i18n.localize("OATHHAMMER.Dialog.LuckSpend")} (${luckSpend}LP${luckIsHuman ? " ๐ค" : ""})`)
const explodedCount = diceResults.filter(d => d.exploded).length
if (explodeOn5) modParts.push(`๐ฅ ${game.i18n.localize("OATHHAMMER.Dialog.ExplodeOn5")}`)
if (explodedCount > 0) modParts.push(`๐ฅ ${explodedCount} ${game.i18n.localize("OATHHAMMER.Roll.Exploded")}`)
const modLine = modParts.length ? `${modParts.join(" ยท ")}
` : ""
const content = `
${skillLabel} (${attrLabel} ${attrRank}) + ${skillLabel} ${skillRank}
${colorEmoji} ${totalDice}d6 (${threshold}+)
${modLine}
${diceHtml}
${successes} ${game.i18n.localize("OATHHAMMER.Roll.Successes")}
โ ${game.i18n.localize("OATHHAMMER.Roll.RollDamage")} (SV ${successes})
`
const rollMode = visibility ?? game.settings.get("core", "rollMode")
const flagData = { actorUuid: actor.uuid, weaponUuid: weapon.uuid, attackSuccesses: successes }
const msgData = {
speaker: ChatMessage.getSpeaker({ actor }),
content,
rolls: rolls,
sound: CONFIG.sounds.dice,
flags: { "fvtt-oath-hammer": { weaponAttack: flagData } },
}
ChatMessage.applyRollMode(msgData, rollMode)
await ChatMessage.create(msgData)
return { successes }
}
// ============================================================
// WEAPON DAMAGE ROLL
// ============================================================
/**
* Roll weapon damage and post to chat.
*
* @param {Actor} actor The attacking actor
* @param {Item} weapon The weapon item
* @param {object} options From OathHammerWeaponDialog.promptDamage()
*/
export async function rollWeaponDamage(actor, weapon, options = {}) {
const { sv = 0, damageBonus = 0, visibility, autoDamageBonus = 0 } = options
const sys = weapon.system
const actorSys = actor.system
const hasBrutal = sys.traits.has("brutal")
const hasDeadly = sys.traits.has("deadly")
const colorType = hasDeadly ? "black" : hasBrutal ? "red" : "white"
const threshold = hasDeadly ? 2 : hasBrutal ? 3 : 4
const colorEmoji = hasDeadly ? "โฌ" : hasBrutal ? "๐ด" : "โฌ"
const colorLabel = hasDeadly ? "Black" : hasBrutal ? "Red" : "White"
const isRangedDmg = sys.proficiencyGroup === "bows" || sys.proficiencyGroup === "throwing"
const dmgSkillKey = (sys.skillOverride && SYSTEM.SKILLS[sys.skillOverride]) ? sys.skillOverride : (isRangedDmg ? "shooting" : "fighting")
const dmgAttrKey = SYSTEM.SKILLS[dmgSkillKey].attribute
const dmgAttrRank = actorSys.attributes[dmgAttrKey].rank
const baseDamageDice = sys.usesMight ? Math.max(dmgAttrRank + sys.damageMod, 1) : Math.max(sys.damageMod, 1)
const totalDice = Math.max(baseDamageDice + sv + damageBonus + autoDamageBonus, 1)
const { roll, rolls, successes, diceResults } = await _rollPool(totalDice, threshold)
const diceHtml = _diceHtml(diceResults, threshold)
const modParts = []
if (sv > 0) modParts.push(`+${sv} SV`)
if (damageBonus !== 0) modParts.push(`${damageBonus > 0 ? "+" : ""}${damageBonus} ${game.i18n.localize("OATHHAMMER.Dialog.DamageModifier")}`)
if (autoDamageBonus > 0) modParts.push(`+${autoDamageBonus} auto`)
const explodedCount = diceResults.filter(d => d.exploded).length
if (explodedCount > 0) modParts.push(`๐ฅ ${explodedCount} ${game.i18n.localize("OATHHAMMER.Roll.Exploded")}`)
const modLine = modParts.length ? `${modParts.join(" ยท ")}
` : ""
const apNote = sys.ap > 0 ? `AP ${sys.ap} ` : ""
const content = `
${sys.damageLabel} = ${baseDamageDice}d6 ${sv > 0 ? `+${sv} SV` : ""}
${colorEmoji} ${totalDice}d6 (${threshold}+) ${colorLabel}
${modLine}
${diceHtml}
${successes} ${game.i18n.localize("OATHHAMMER.Roll.Damage")}
${apNote}
`
const rollMode = visibility ?? game.settings.get("core", "rollMode")
const msgData = {
speaker: ChatMessage.getSpeaker({ actor }),
content,
rolls: rolls,
sound: CONFIG.sounds.dice,
}
ChatMessage.applyRollMode(msgData, rollMode)
await ChatMessage.create(msgData)
return { successes }
}
// ============================================================
// SPELL CAST ROLL
// ============================================================
/**
* Roll a spell casting check (Magic / Intelligence) and post to chat.
* Counts dice showing 1 and adds Arcane Stress to the actor.
*
* @param {Actor} actor The caster
* @param {Item} spell The spell item
* @param {object} options From OathHammerSpellDialog.prompt()
*/
export async function rollSpellCast(actor, spell, options = {}) {
const {
dv = spell.system.difficultyValue,
enhancement = "none",
stressCost = 0,
poolPenalty = 0,
redDice = false,
noStress = false,
elementalBonus = 0,
bonus = 0,
poolSize = null,
grimPenalty = 0,
visibility,
explodeOn5 = false,
luckSpend = 0,
luckIsHuman = false,
} = options
const sys = spell.system
const actorSys = actor.system
const intRank = actorSys.attributes.intelligence.rank
const magicRank = actorSys.skills.magic.rank
const luckDicePerPoint = luckIsHuman ? 3 : 2
const baseDice = intRank + magicRank + bonus + poolPenalty + elementalBonus + grimPenalty + (luckSpend * luckDicePerPoint)
// poolSize: voluntary reduction (p.101) โ clamped to [1, baseDice]
const totalDice = poolSize !== null
? Math.max(1, Math.min(poolSize + bonus + poolPenalty + elementalBonus + grimPenalty + (luckSpend * luckDicePerPoint), baseDice))
: Math.max(baseDice, 1)
const threshold = redDice ? 3 : 4
const colorEmoji = redDice ? "๐ด" : "โฌ"
const { roll, rolls, successes, diceResults } = await _rollPool(totalDice, threshold, explodeOn5)
const diceHtml = _diceHtml(diceResults, threshold)
// Count 1s for Arcane Stress (unless Safe Spell enhancement)
const onesCount = noStress ? 0 : diceResults.filter(d => d.val === 1 && !d.exploded).length
const totalStressGain = stressCost + onesCount
const isSuccess = successes >= dv
// Update arcane stress
if (totalStressGain > 0) {
const currentStress = actorSys.arcaneStress.value
await actor.update({ "system.arcaneStress.value": currentStress + totalStressGain })
}
if (luckSpend > 0) {
await actor.update({ "system.luck.value": Math.max(0, (actorSys.luck?.value ?? 0) - luckSpend) })
}
const newStress = (actorSys.arcaneStress.value ?? 0) + totalStressGain
const stressMax = actorSys.arcaneStress.threshold
const isBlocked = newStress >= stressMax
const skillLabel = game.i18n.localize("OATHHAMMER.Skill.Magic")
const attrLabel = game.i18n.localize("OATHHAMMER.Attribute.Intelligence")
const resultClass = isSuccess ? "roll-success" : "roll-failure"
const resultLabel = isSuccess
? game.i18n.localize("OATHHAMMER.Roll.Success")
: game.i18n.localize("OATHHAMMER.Roll.Failure")
const modParts = []
if (poolSize !== null && poolSize < intRank + magicRank) modParts.push(`๐ฒ ${poolSize}d ${game.i18n.localize("OATHHAMMER.Dialog.PoolSizeReduced")}`)
if (bonus !== 0) modParts.push(`${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}`)
if (poolPenalty !== 0) modParts.push(`${poolPenalty} ${game.i18n.localize("OATHHAMMER.Enhancement." + _cap(enhancement))}`)
if (elementalBonus > 0) modParts.push(`+${elementalBonus} ${game.i18n.localize("OATHHAMMER.Dialog.ElementMet")}`)
if (grimPenalty < 0) modParts.push(`${grimPenalty} ${game.i18n.localize("OATHHAMMER.Dialog.GrimoireNo")}`)
if (luckSpend > 0) modParts.push(`+${luckSpend * luckDicePerPoint} ${game.i18n.localize("OATHHAMMER.Dialog.LuckSpend")} (${luckSpend}LP${luckIsHuman ? " ๐ค" : ""})`)
const explodedCountSpell = diceResults.filter(d => d.exploded).length
if (explodeOn5) modParts.push(`๐ฅ ${game.i18n.localize("OATHHAMMER.Dialog.ExplodeOn5")}`)
if (explodedCountSpell > 0) modParts.push(`๐ฅ ${explodedCountSpell} ${game.i18n.localize("OATHHAMMER.Roll.Exploded")}`)
const modLine = modParts.length ? `${modParts.join(" ยท ")}
` : ""
const stressLine = `
๐ง ${game.i18n.localize("OATHHAMMER.Label.ArcaneStress")}: +${totalStressGain}
(${onesCount} ร 1s + ${stressCost} enh.) โ ${newStress}/${stressMax}
${isBlocked ? ` โ ${game.i18n.localize("OATHHAMMER.Label.StressBlocked")}` : ""}
`
const content = `
${attrLabel} ${intRank} + ${skillLabel} ${magicRank}
${colorEmoji} ${totalDice}d6 (${threshold}+)
${modLine}
${diceHtml}
${successes} / ${dv}
${resultLabel}
${stressLine}
`
const rollMode = visibility ?? game.settings.get("core", "rollMode")
const msgData = {
speaker: ChatMessage.getSpeaker({ actor }),
content,
rolls: rolls,
sound: CONFIG.sounds.dice,
}
ChatMessage.applyRollMode(msgData, rollMode)
await ChatMessage.create(msgData)
return { successes, dv, isSuccess, totalStressGain, newStress }
}
// ============================================================
// MIRACLE CAST ROLL
// ============================================================
/**
* Roll a miracle invocation check (Magic / Willpower) and post to chat.
* On failure, warns the player they are blocked from miracles for the day.
*
* @param {Actor} actor The caster
* @param {Item} miracle The miracle item
* @param {object} options From OathHammerMiracleDialog.prompt()
*/
export async function rollMiracleCast(actor, miracle, options = {}) {
const {
dv = 1,
isRitual = false,
bonus = 0,
visibility,
explodeOn5 = false,
luckSpend = 0,
luckIsHuman = false,
} = options
const sys = miracle.system
const actorSys = actor.system
const wpRank = actorSys.attributes.willpower.rank
const magicRank = actorSys.skills.magic.rank
const luckDicePerPoint = luckIsHuman ? 3 : 2
const totalDice = Math.max(wpRank + magicRank + bonus + (luckSpend * luckDicePerPoint), 1)
const threshold = 4
const colorEmoji = "โฌ"
const { roll, rolls, successes, diceResults } = await _rollPool(totalDice, threshold, explodeOn5)
const diceHtml = _diceHtml(diceResults, threshold)
const isSuccess = successes >= dv
if (luckSpend > 0) {
await actor.update({ "system.luck.value": Math.max(0, (actorSys.luck?.value ?? 0) - luckSpend) })
}
const skillLabel = game.i18n.localize("OATHHAMMER.Skill.Magic")
const attrLabel = game.i18n.localize("OATHHAMMER.Attribute.Willpower")
const resultClass = isSuccess ? "roll-success" : "roll-failure"
const resultLabel = isSuccess
? game.i18n.localize("OATHHAMMER.Roll.Success")
: game.i18n.localize("OATHHAMMER.Roll.Failure")
const modParts = []
if (bonus !== 0) modParts.push(`${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}`)
if (luckSpend > 0) modParts.push(`+${luckSpend * luckDicePerPoint} ${game.i18n.localize("OATHHAMMER.Dialog.LuckSpend")} (${luckSpend}LP${luckIsHuman ? " ๐ค" : ""})`)
const explodedCountMiracle = diceResults.filter(d => d.exploded).length
if (explodeOn5) modParts.push(`๐ฅ ${game.i18n.localize("OATHHAMMER.Dialog.ExplodeOn5")}`)
if (explodedCountMiracle > 0) modParts.push(`๐ฅ ${explodedCountMiracle} ${game.i18n.localize("OATHHAMMER.Roll.Exploded")}`)
const modLine = modParts.length ? `${modParts.join(" ยท ")}
` : ""
const blockedLine = !isSuccess
? `โ ${game.i18n.localize("OATHHAMMER.Roll.MiracleBlocked")}
`
: ""
const dvNote = isRitual
? `DV ${dv} (${game.i18n.localize("OATHHAMMER.Label.Ritual")})`
: `DV ${dv}`
const content = `
${attrLabel} ${wpRank} + ${skillLabel} ${magicRank}
${colorEmoji} ${totalDice}d6 (${threshold}+)
${modLine}
${diceHtml}
${successes} / ${dv}
${resultLabel}
${blockedLine}
`
const rollMode = visibility ?? game.settings.get("core", "rollMode")
const msgData = {
speaker: ChatMessage.getSpeaker({ actor }),
content,
rolls: rolls,
sound: CONFIG.sounds.dice,
}
ChatMessage.applyRollMode(msgData, rollMode)
await ChatMessage.create(msgData)
if (!isSuccess) {
await actor.update({ "system.miracleBlocked": true })
}
return { successes, dv, isSuccess }
}
function _cap(s) { return s ? s.charAt(0).toUpperCase() + s.slice(1) : "" }
// ============================================================
// DEFENSE ROLL
// ============================================================
/**
* Roll a defense check (Agility/Might + Defense skill) and post to chat.
*
* @param {Actor} actor
* @param {object} options From OathHammerDefenseDialog.prompt()
*/
export async function rollDefense(actor, options = {}) {
const {
attackType = "melee",
attrRank = 0,
attrChoice = "agility",
redDice = false,
traitBonus = 0,
armorPenalty = 0,
bonus = 0,
visibility,
} = options
const defRank = actor.system.skills.defense.rank
const totalDice = Math.max(attrRank + defRank + traitBonus + armorPenalty + bonus, 1)
const threshold = redDice ? 3 : 4
const colorEmoji = redDice ? "๐ด" : "โฌ"
const { roll, rolls, successes, diceResults } = await _rollPool(totalDice, threshold)
const diceHtml = _diceHtml(diceResults, threshold)
const attrLabel = game.i18n.localize(`OATHHAMMER.Attribute.${attrChoice.charAt(0).toUpperCase() + attrChoice.slice(1)}`)
const skillLabel = game.i18n.localize("OATHHAMMER.Skill.Defense")
const typeLabel = game.i18n.localize(attackType === "ranged" ? "OATHHAMMER.Dialog.DefenseRanged" : "OATHHAMMER.Dialog.DefenseMelee")
const modParts = []
if (traitBonus > 0) modParts.push(`+${traitBonus} ${game.i18n.localize(attackType === "melee" ? "OATHHAMMER.WeaponTrait.Parry" : "OATHHAMMER.WeaponTrait.Block")}`)
if (armorPenalty < 0) modParts.push(`${armorPenalty} ${game.i18n.localize("OATHHAMMER.Dialog.ArmorPenalty")}`)
if (bonus !== 0) modParts.push(`${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}`)
const explodedCountDef = diceResults.filter(d => d.exploded).length
if (explodedCountDef > 0) modParts.push(`๐ฅ ${explodedCountDef} ${game.i18n.localize("OATHHAMMER.Roll.Exploded")}`)
const modLine = modParts.length ? `${modParts.join(" ยท ")}
` : ""
const content = `
${attrLabel} ${attrRank} + ${skillLabel} ${defRank}
${colorEmoji} ${totalDice}d6 (${threshold}+)
${modLine}
${diceHtml}
${successes} ${game.i18n.localize("OATHHAMMER.Roll.Successes")}
${game.i18n.localize("OATHHAMMER.Roll.DefenseResult")}
`
const rollMode = visibility ?? game.settings.get("core", "rollMode")
const msgData = {
speaker: ChatMessage.getSpeaker({ actor }),
content,
rolls: rolls,
sound: CONFIG.sounds.dice,
}
ChatMessage.applyRollMode(msgData, rollMode)
await ChatMessage.create(msgData)
return { successes }
}
// ============================================================
// WEAPON DEFENSE ROLL
// ============================================================
/**
* Roll a defense check triggered from a specific weapon, applying the
* weapon's Parry / Block traits, and post to chat.
*
* Pool = (Agility or Might) + Defense skill
* + traitBonus (Parry: +1 if two Parry weapons; Block: +1 vs ranged)
* + armorPenalty (โค 0)
* + diminishPenalty (0 / โ2 / โ4 for 1st / 2nd / 3rd+ defense)
* + bonus
*
* Parry trait โ red dice (3+) when defending vs melee attacks
* Block trait โ red dice (3+) + +1 bonus when defending vs ranged attacks
*
* @param {Actor} actor
* @param {Item} weapon The weapon used to defend
* @param {object} options From OathHammerWeaponDialog.promptDefense()
*/
export async function rollWeaponDefense(actor, weapon, options = {}) {
const {
attackType = "melee",
attrRank = 0,
attrChoice = "agility",
colorOverride = "white",
traitBonus = 0,
armorPenalty = 0,
diminishPenalty = 0,
bonus = 0,
visibility,
explodeOn5 = false,
luckSpend = 0,
luckIsHuman = false,
} = options
const defRank = actor.system.skills.defense.rank
const luckDicePerPoint = luckIsHuman ? 3 : 2
const totalDice = Math.max(attrRank + defRank + traitBonus + armorPenalty + diminishPenalty + bonus + (luckSpend * luckDicePerPoint), 1)
const threshold = colorOverride === "black" ? 2 : colorOverride === "red" ? 3 : 4
const colorEmoji = colorOverride === "black" ? "โฌ" : colorOverride === "red" ? "๐ด" : "โฌ"
if (luckSpend > 0) {
const currentLuck = actor.system.luck?.value ?? 0
await actor.update({ "system.luck.value": Math.max(0, currentLuck - luckSpend) })
}
const { roll, rolls, successes, diceResults } = await _rollPool(totalDice, threshold, explodeOn5)
const diceHtml = _diceHtml(diceResults, threshold)
const attrLabel = game.i18n.localize(`OATHHAMMER.Attribute.${_cap(attrChoice)}`)
const skillLabel = game.i18n.localize("OATHHAMMER.Skill.Defense")
const typeLabel = game.i18n.localize(attackType === "ranged" ? "OATHHAMMER.Dialog.DefenseRanged" : "OATHHAMMER.Dialog.DefenseMelee")
const modParts = []
if (traitBonus > 0) modParts.push(`+${traitBonus} ${game.i18n.localize(attackType === "melee" ? "OATHHAMMER.WeaponTrait.Parry" : "OATHHAMMER.WeaponTrait.Block")}`)
if (armorPenalty < 0) modParts.push(`${armorPenalty} ${game.i18n.localize("OATHHAMMER.Dialog.ArmorPenalty")}`)
if (diminishPenalty < 0) modParts.push(`${diminishPenalty} ${game.i18n.localize("OATHHAMMER.Dialog.DiminishingDefense")}`)
if (bonus !== 0) modParts.push(`${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}`)
if (luckSpend > 0) modParts.push(`+${luckSpend * luckDicePerPoint} ${game.i18n.localize("OATHHAMMER.Dialog.LuckSpend")} (${luckSpend}LP${luckIsHuman ? " ๐ค" : ""})`)
const explodedCountWDef = diceResults.filter(d => d.exploded).length
if (explodeOn5) modParts.push(`๐ฅ ${game.i18n.localize("OATHHAMMER.Dialog.ExplodeOn5")}`)
if (explodedCountWDef > 0) modParts.push(`๐ฅ ${explodedCountWDef} ${game.i18n.localize("OATHHAMMER.Roll.Exploded")}`)
const modLine = modParts.length ? `${modParts.join(" ยท ")}
` : ""
const content = `
${attrLabel} ${attrRank} + ${skillLabel} ${defRank}
${colorEmoji} ${totalDice}d6 (${threshold}+)
${modLine}
${diceHtml}
${successes} ${game.i18n.localize("OATHHAMMER.Roll.Successes")}
${game.i18n.localize("OATHHAMMER.Roll.DefenseResult")}
`
const rollMode = visibility ?? game.settings.get("core", "rollMode")
const msgData = {
speaker: ChatMessage.getSpeaker({ actor }),
content,
rolls: rolls,
sound: CONFIG.sounds.dice,
}
ChatMessage.applyRollMode(msgData, rollMode)
await ChatMessage.create(msgData)
return { successes }
}
// ============================================================
// ARMOR ROLL
// ============================================================
/**
* Roll an armor saving roll (AV dice โ AP) and post to chat.
* Unlike other rolls, AP can reduce the pool to 0 (armor bypassed).
* Each success reduces incoming damage by 1.
*
* @param {Actor} actor
* @param {Item} armor
* @param {object} options From OathHammerArmorDialog.prompt()
*/
export async function rollArmorSave(actor, armor, options = {}) {
const {
av = armor.system.armorValue ?? 0,
isReinforced = false,
colorOverride = null,
apPenalty = 0,
bonus = 0,
visibility,
explodeOn5 = false,
luckSpend = 0,
luckIsHuman = false,
} = options
const luckDicePerPoint = luckIsHuman ? 3 : 2
// Armor CAN be reduced to 0 dice (fully bypassed by AP) โ luck can still rescue
const totalDice = Math.max(av + apPenalty + bonus + (luckSpend * luckDicePerPoint), 0)
const colorType = colorOverride || (isReinforced ? "red" : "white")
const threshold = colorType === "black" ? 2 : colorType === "red" ? 3 : 4
const colorEmoji = colorType === "black" ? "โฌ" : colorType === "red" ? "๐ด" : "โฌ"
if (luckSpend > 0) {
const currentLuck = actor.system.luck?.value ?? 0
await actor.update({ "system.luck.value": Math.max(0, currentLuck - luckSpend) })
}
let successes = 0
let diceHtml = `${game.i18n.localize("OATHHAMMER.Roll.ArmorBypassed")} `
let roll
let rolls = []
let armorDiceResults = []
if (totalDice > 0) {
const result = await _rollPool(totalDice, threshold, explodeOn5)
roll = result.roll
rolls = result.rolls
successes = result.successes
armorDiceResults = result.diceResults
diceHtml = _diceHtml(result.diceResults, threshold)
} else {
// Zero dice โ create a dummy roll with no results so Foundry can still attach it
roll = new Roll("0d6")
await roll.evaluate()
rolls = [roll]
}
const modParts = []
if (apPenalty < 0) modParts.push(`AP ${apPenalty}`)
if (bonus !== 0) modParts.push(`${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}`)
if (luckSpend > 0) modParts.push(`+${luckSpend * luckDicePerPoint} ${game.i18n.localize("OATHHAMMER.Dialog.LuckSpend")} (${luckSpend}LP${luckIsHuman ? " ๐ค" : ""})`)
const explodedCountArmor = armorDiceResults.filter(d => d.exploded).length
if (explodeOn5) modParts.push(`๐ฅ ${game.i18n.localize("OATHHAMMER.Dialog.ExplodeOn5")}`)
if (explodedCountArmor > 0) modParts.push(`๐ฅ ${explodedCountArmor} ${game.i18n.localize("OATHHAMMER.Roll.Exploded")}`)
const modLine = modParts.length ? `${modParts.join(" ยท ")}
` : ""
const content = `
${game.i18n.localize("OATHHAMMER.Roll.ArmorRoll")}
${colorEmoji} ${totalDice}d6 (${threshold}+)
${modLine}
${diceHtml}
${successes} ${game.i18n.localize("OATHHAMMER.Roll.Successes")}
โ${successes} ${game.i18n.localize("OATHHAMMER.Roll.Damage")}
`
const rollMode = visibility ?? game.settings.get("core", "rollMode")
const msgData = {
speaker: ChatMessage.getSpeaker({ actor }),
content,
rolls: rolls,
sound: CONFIG.sounds.dice,
}
ChatMessage.applyRollMode(msgData, rollMode)
await ChatMessage.create(msgData)
return { successes, totalDice }
}
// ============================================================
// INITIATIVE ROLL
// ============================================================
/**
* Roll an initiative check for an actor and post the result to chat.
*
* Characters: opposed Leadership check (Fate + Leadership skill, DV=0).
* NPCs: pool of Fate rank + initiativeBonus dice (no skill), threshold 4+.
*
* @param {Actor} actor
* @param {object} [options]
* @param {number} [options.bonus] Extra dice modifier
* @param {string} [options.visibility] Roll mode
* @param {boolean} [options.explodeOn5] Explode on 5+
* @returns {Promise<{successes: number, dv: number, isSuccess: null}>}
*/
export async function rollInitiativeCheck(actor, options = {}) {
const { bonus = 0, visibility, explodeOn5 = false } = options
if (actor.type === "character") {
return rollSkillCheck(actor, "leadership", 0, {
bonus,
visibility,
explodeOn5,
flavor: game.i18n.localize("OATHHAMMER.Roll.Initiative"),
})
}
// NPC: find Leadership skillnpc item, fall back to Fate rank + initiativeBonus
const leadershipSkill = actor.items.find(
i => i.type === "skillnpc" && i.name.toLowerCase() === "leadership"
)
if (leadershipSkill) {
return rollNPCSkill(actor, leadershipSkill, { bonus, visibility })
}
// Fallback: Fate rank + initiativeBonus
const sys = actor.system
const fateRank = sys.attributes?.fate?.rank ?? 1
const initBonus = sys.initiativeBonus ?? 0
const pool = Math.max(fateRank + initBonus + bonus, 1)
const threshold = 4
const { roll, rolls, successes, diceResults } = await _rollPool(pool, threshold, explodeOn5)
const diceHtml = _diceHtml(diceResults, threshold)
const modParts = []
if (bonus !== 0) modParts.push(`${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}`)
const explodedCountInit = diceResults.filter(d => d.exploded).length
if (explodeOn5) modParts.push(`๐ฅ ${game.i18n.localize("OATHHAMMER.Dialog.ExplodeOn5")}`)
if (explodedCountInit > 0) modParts.push(`๐ฅ ${explodedCountInit} ${game.i18n.localize("OATHHAMMER.Roll.Exploded")}`)
const modLine = modParts.length ? `${modParts.join(" ยท ")}
` : ""
const content = `
${game.i18n.localize("OATHHAMMER.Attribute.Fate")} ${fateRank}${initBonus ? ` + ${initBonus}` : ""}
โฌ ${pool}d6 (4+)
${modLine}
${diceHtml}
${successes}
${game.i18n.localize("OATHHAMMER.Roll.Opposed")}
`
const rollMode = visibility ?? game.settings.get("core", "rollMode")
const msgData = { speaker: ChatMessage.getSpeaker({ actor }), content, rolls: rolls, sound: CONFIG.sounds.dice }
ChatMessage.applyRollMode(msgData, rollMode)
await ChatMessage.create(msgData)
return { successes, dv: 0, isSuccess: null }
}
// ============================================================
// NPC SKILL ROLL
// ============================================================
/**
* Roll an NPC skill check (skillnpc item) and post to chat.
*
* @param {Actor} actor The NPC/creature actor
* @param {Item} skillItem The skillnpc item
* @param {object} options
*/
export async function rollNPCSkill(actor, skillItem, options = {}) {
const { bonus = 0, colorOverride, visibility, explodeOn5 = false } = options
const sys = skillItem.system
const colorType = colorOverride || sys.colorDiceType
const threshold = colorType === "black" ? 2 : colorType === "red" ? 3 : 4
const colorEmoji = colorType === "black" ? "โฌ" : colorType === "red" ? "๐ด" : "โฌ"
const totalDice = Math.max(sys.dicePool + bonus, 1)
const { roll, rolls, successes, diceResults } = await _rollPool(totalDice, threshold, explodeOn5)
const diceHtml = _diceHtml(diceResults, threshold)
const explodedCount = diceResults.filter(d => d.exploded).length
const modParts = []
if (bonus !== 0) modParts.push(`${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}`)
if (explodeOn5) modParts.push(`๐ฅ ${game.i18n.localize("OATHHAMMER.Dialog.ExplodeOn5")}`)
if (explodedCount > 0) modParts.push(`๐ฅ ${explodedCount} ${game.i18n.localize("OATHHAMMER.Roll.Exploded")}`)
const modLine = modParts.length ? `${modParts.join(" ยท ")}
` : ""
const content = `
${colorEmoji} ${totalDice}d6 (${threshold}+)
${modLine}
${diceHtml}
${successes} ${game.i18n.localize("OATHHAMMER.Roll.Successes")}
`
const rollMode = visibility ?? game.settings.get("core", "rollMode")
const msgData = { speaker: ChatMessage.getSpeaker({ actor }), content, rolls: rolls, sound: CONFIG.sounds.dice }
ChatMessage.applyRollMode(msgData, rollMode)
await ChatMessage.create(msgData)
return { successes }
}
/**
* NPC weapon attack roll โ uses NPC's flat attackBonus as dice pool.
* Rolls white dice (4+) with optional bonus modifier.
*/
export async function rollNPCWeaponAttack(actor, weapon, options = {}) {
const { bonus = 0, visibility } = options
const sys = actor.system
const basePool = sys.attackBonus ?? 0
const totalDice = Math.max(basePool + bonus, 1)
const threshold = 4
const colorEmoji = "โฌ"
const { roll, rolls, successes, diceResults } = await _rollPool(totalDice, threshold, false)
const diceHtml = _diceHtml(diceResults, threshold)
const modParts = []
if (bonus !== 0) modParts.push(`${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}`)
if (diceResults.filter(d => d.exploded).length > 0) modParts.push(`๐ฅ ${diceResults.filter(d => d.exploded).length} ${game.i18n.localize("OATHHAMMER.Roll.Exploded")}`)
const modLine = modParts.length ? `${modParts.join(" ยท ")}
` : ""
const content = `
${colorEmoji} ${totalDice}d6 (${threshold}+)
${modLine}
${diceHtml}
${successes} ${game.i18n.localize("OATHHAMMER.Roll.Successes")}
`
const rollMode = visibility ?? game.settings.get("core", "rollMode")
const msgData = { speaker: ChatMessage.getSpeaker({ actor }), content, rolls: rolls, sound: CONFIG.sounds.dice }
ChatMessage.applyRollMode(msgData, rollMode)
await ChatMessage.create(msgData)
return { successes }
}
/**
* NPC weapon damage roll โ uses NPC damageBonus + weapon damageMod as dice pool.
*/
export async function rollNPCWeaponDamage(actor, weapon, options = {}) {
const { bonus = 0, visibility } = options
const sys = actor.system
const basePool = (sys.damageBonus ?? 0) + (weapon.system.damageMod ?? 0)
const totalDice = Math.max(basePool + bonus, 1)
const threshold = 4
const colorEmoji = "โฌ"
const { roll, rolls, successes, diceResults } = await _rollPool(totalDice, threshold, false)
const diceHtml = _diceHtml(diceResults, threshold)
const modParts = []
if (bonus !== 0) modParts.push(`${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}`)
if (diceResults.filter(d => d.exploded).length > 0) modParts.push(`๐ฅ ${diceResults.filter(d => d.exploded).length} ${game.i18n.localize("OATHHAMMER.Roll.Exploded")}`)
const modLine = modParts.length ? `${modParts.join(" ยท ")}
` : ""
const content = `
${colorEmoji} ${totalDice}d6 (${threshold}+)
${game.i18n.localize("OATHHAMMER.Label.Damage")}: ${weapon.system.damageLabel}
${modLine}
${diceHtml}
${successes} ${game.i18n.localize("OATHHAMMER.Roll.Successes")}
`
const rollMode = visibility ?? game.settings.get("core", "rollMode")
const msgData = { speaker: ChatMessage.getSpeaker({ actor }), content, rolls: rolls, sound: CONFIG.sounds.dice }
ChatMessage.applyRollMode(msgData, rollMode)
await ChatMessage.create(msgData)
return { successes }
}
/**
* NPC armor dice roll โ rolls actor's armorDice.value dice with armorDice.colorDiceType color.
*/
export async function rollNPCArmor(actor, options = {}) {
const { bonus = 0, colorOverride, visibility, explodeOn5 = false } = options
const sys = actor.system
const basePool = sys.armorDice?.value ?? 0
const colorType = colorOverride || sys.armorDice?.colorDiceType || "white"
const threshold = colorType === "black" ? 2 : colorType === "red" ? 3 : 4
const colorEmoji = colorType === "black" ? "โฌ" : colorType === "red" ? "๐ด" : "โฌ"
const totalDice = Math.max(basePool + bonus, 1)
const { roll, rolls, successes, diceResults } = await _rollPool(totalDice, threshold, explodeOn5)
const diceHtml = _diceHtml(diceResults, threshold)
const explodedCount = diceResults.filter(d => d.exploded).length
const modParts = []
if (bonus !== 0) modParts.push(`${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}`)
if (explodeOn5) modParts.push(`๐ฅ ${game.i18n.localize("OATHHAMMER.Dialog.ExplodeOn5")}`)
if (explodedCount > 0) modParts.push(`๐ฅ ${explodedCount} ${game.i18n.localize("OATHHAMMER.Roll.Exploded")}`)
const modLine = modParts.length ? `${modParts.join(" ยท ")}
` : ""
const label = game.i18n.localize("OATHHAMMER.Label.ArmorDice")
const content = `
${colorEmoji} ${totalDice}d6 (${threshold}+)
${modLine}
${diceHtml}
${successes} ${game.i18n.localize("OATHHAMMER.Roll.Successes")}
โ${successes} ${game.i18n.localize("OATHHAMMER.Roll.Damage")}
`
const rollMode = visibility ?? game.settings.get("core", "rollMode")
const msgData = { speaker: ChatMessage.getSpeaker({ actor }), content, rolls: rolls, sound: CONFIG.sounds.dice }
ChatMessage.applyRollMode(msgData, rollMode)
await ChatMessage.create(msgData)
return { successes }
}
/**
* NPC spell cast โ flat dice pool, no arcane stress, posts DV success/failure to chat.
*/
export async function rollNPCSpell(actor, spell, options = {}) {
const { dicePool = 3, bonus = 0, colorOverride, visibility, explodeOn5 = false } = options
const dv = spell.system.difficultyValue ?? 1
const colorType = colorOverride || "white"
const threshold = colorType === "black" ? 2 : colorType === "red" ? 3 : 4
const colorEmoji = colorType === "black" ? "โฌ" : colorType === "red" ? "๐ด" : "โฌ"
const totalDice = Math.max(dicePool + bonus, 1)
const { roll, rolls, successes, diceResults } = await _rollPool(totalDice, threshold, explodeOn5)
const diceHtml = _diceHtml(diceResults, threshold)
const isSuccess = successes >= dv
const explodedCount = diceResults.filter(d => d.exploded).length
const modParts = []
if (bonus !== 0) modParts.push(`${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}`)
if (explodeOn5) modParts.push(`๐ฅ ${game.i18n.localize("OATHHAMMER.Dialog.ExplodeOn5")}`)
if (explodedCount > 0) modParts.push(`๐ฅ ${explodedCount} ${game.i18n.localize("OATHHAMMER.Roll.Exploded")}`)
const modLine = modParts.length ? `${modParts.join(" ยท ")}
` : ""
const resultClass = isSuccess ? "roll-success" : "roll-failure"
const resultLabel = isSuccess
? game.i18n.localize("OATHHAMMER.Roll.Success")
: game.i18n.localize("OATHHAMMER.Roll.Failure")
const content = `
${colorEmoji} ${totalDice}d6 (${threshold}+)
${modLine}
${diceHtml}
${successes} / ${dv}
${resultLabel}
`
const rollMode = visibility ?? game.settings.get("core", "rollMode")
const msgData = { speaker: ChatMessage.getSpeaker({ actor }), content, rolls, sound: CONFIG.sounds.dice }
ChatMessage.applyRollMode(msgData, rollMode)
await ChatMessage.create(msgData)
return { successes, dv, isSuccess }
}
/**
* NPC miracle invocation โ flat dice pool, no blocked tracking, posts DV success/failure to chat.
*/
export async function rollNPCMiracle(actor, miracle, options = {}) {
const { dicePool = 3, bonus = 0, visibility, explodeOn5 = false } = options
const dv = 1
const threshold = 4
const colorEmoji = "โฌ"
const totalDice = Math.max(dicePool + bonus, 1)
const { roll, rolls, successes, diceResults } = await _rollPool(totalDice, threshold, explodeOn5)
const diceHtml = _diceHtml(diceResults, threshold)
const isSuccess = successes >= dv
const explodedCount = diceResults.filter(d => d.exploded).length
const modParts = []
if (bonus !== 0) modParts.push(`${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}`)
if (explodeOn5) modParts.push(`๐ฅ ${game.i18n.localize("OATHHAMMER.Dialog.ExplodeOn5")}`)
if (explodedCount > 0) modParts.push(`๐ฅ ${explodedCount} ${game.i18n.localize("OATHHAMMER.Roll.Exploded")}`)
const modLine = modParts.length ? `${modParts.join(" ยท ")}
` : ""
const resultClass = isSuccess ? "roll-success" : "roll-failure"
const resultLabel = isSuccess
? game.i18n.localize("OATHHAMMER.Roll.Success")
: game.i18n.localize("OATHHAMMER.Roll.Failure")
const content = `
${colorEmoji} ${totalDice}d6 (${threshold}+)
${modLine}
${diceHtml}
${successes} / ${dv}
${resultLabel}
`
const rollMode = visibility ?? game.settings.get("core", "rollMode")
const msgData = { speaker: ChatMessage.getSpeaker({ actor }), content, rolls, sound: CONFIG.sounds.dice }
ChatMessage.applyRollMode(msgData, rollMode)
await ChatMessage.create(msgData)
return { successes, dv, isSuccess }
}
/**
* NPC attack damage roll โ flat dice pool from the npcattack item, no Might.
*/
export async function rollNPCAttackDamage(actor, attack, options = {}) {
const { bonus = 0, visibility, explodeOn5 = false } = options
const sys = attack.system
const colorType = sys.colorDiceType || "white"
const threshold = colorType === "black" ? 2 : colorType === "red" ? 3 : 4
const colorEmoji = colorType === "black" ? "โฌ" : colorType === "red" ? "๐ด" : "โฌ"
const totalDice = Math.max((sys.damageDice ?? 1) + bonus, 1)
const ap = sys.ap ?? 0
const { roll, rolls, successes, diceResults } = await _rollPool(totalDice, threshold, explodeOn5)
const diceHtml = _diceHtml(diceResults, threshold)
const explodedCount = diceResults.filter(d => d.exploded).length
const modParts = []
if (bonus !== 0) modParts.push(`${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}`)
if (ap > 0) modParts.push(`AP ${ap}`)
if (explodeOn5) modParts.push(`๐ฅ ${game.i18n.localize("OATHHAMMER.Dialog.ExplodeOn5")}`)
if (explodedCount > 0) modParts.push(`๐ฅ ${explodedCount} ${game.i18n.localize("OATHHAMMER.Roll.Exploded")}`)
const modLine = modParts.length ? `${modParts.join(" ยท ")}
` : ""
const content = `
${colorEmoji} ${totalDice}d6 (${threshold}+)
${ap > 0 ? `AP ${ap} ` : ""}
${modLine}
${diceHtml}
${successes} ${game.i18n.localize("OATHHAMMER.Roll.Successes")}
`
const rollMode = visibility ?? game.settings.get("core", "rollMode")
const msgData = { speaker: ChatMessage.getSpeaker({ actor }), content, rolls, sound: CONFIG.sounds.dice }
ChatMessage.applyRollMode(msgData, rollMode)
await ChatMessage.create(msgData)
return { successes }
}