Files
fvtt-oath-hammer/module/rolls.mjs

1316 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 `<span class="oh-die ${cssClass}${explodedClass}" title="${exploded ? "💥 Exploded" : ""}">${val}</span>`
}).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 ? `<div class="oh-roll-mods">${modParts.join(" · ")}</div>` : ""
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 = `
<div class="oh-roll-card">
<div class="oh-roll-header">${cardFlavor}</div>
<div class="oh-roll-info">
<span>${attrLabel} ${attrRank} + ${skillLabel} ${skillRank}</span>
<span>${colorEmoji} ${totalDice}d6 (${threshold}+)</span>
</div>
${modLine}
<div class="oh-roll-dice">${diceHtml}</div>
<div class="oh-roll-result ${resultClass}">
<span class="oh-roll-successes">${successDisplay}</span>
<span class="oh-roll-verdict">${resultLabel}</span>
</div>
</div>
`
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 = `
<div class="oh-roll-card">
<div class="oh-roll-header">${itemName ?? game.i18n.localize("OATHHAMMER.Label.Rarity")}</div>
<div class="oh-roll-result roll-success">
<span class="oh-roll-verdict">${rarityLabel}${game.i18n.localize("OATHHAMMER.Roll.AutoSuccess")}</span>
</div>
</div>
`
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 `<span class="oh-die ${cssClass}${exploded ? " die-exploded" : ""}" title="${exploded ? "💥" : ""}">${val}</span>`
}).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 ? `<div class="oh-roll-mods">${modParts.join(" · ")}</div>` : ""
const content = `
<div class="oh-roll-card oh-weapon-card">
<div class="oh-roll-header">
<img src="${weapon.img}" class="oh-card-weapon-img" alt="${weapon.name}" />
<span>${weapon.name}${game.i18n.localize("OATHHAMMER.Dialog.Attack")}</span>
</div>
<div class="oh-roll-info">
<span>${skillLabel} (${attrLabel} ${attrRank}) + ${skillLabel} ${skillRank}</span>
<span>${colorEmoji} ${totalDice}d6 (${threshold}+)</span>
</div>
${modLine}
<div class="oh-roll-dice">${diceHtml}</div>
<div class="oh-roll-result">
<span class="oh-roll-successes">${successes} ${game.i18n.localize("OATHHAMMER.Roll.Successes")}</span>
</div>
<div class="oh-weapon-damage-btn-row">
<button type="button" class="oh-roll-damage-btn" data-action="rollWeaponDamage">
${game.i18n.localize("OATHHAMMER.Roll.RollDamage")} (SV ${successes})
</button>
</div>
</div>
`
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 ? `<div class="oh-roll-mods">${modParts.join(" · ")}</div>` : ""
const apNote = sys.ap > 0 ? `<span class="oh-ap-note">AP ${sys.ap}</span>` : ""
const content = `
<div class="oh-roll-card oh-weapon-card">
<div class="oh-roll-header">
<img src="${weapon.img}" class="oh-card-weapon-img" alt="${weapon.name}" />
<span>${weapon.name}${game.i18n.localize("OATHHAMMER.Dialog.Damage")}</span>
</div>
<div class="oh-roll-info">
<span>${sys.damageLabel} = ${baseDamageDice}d6 ${sv > 0 ? `+${sv} SV` : ""}</span>
<span>${colorEmoji} ${totalDice}d6 (${threshold}+) ${colorLabel}</span>
</div>
${modLine}
<div class="oh-roll-dice">${diceHtml}</div>
<div class="oh-roll-result">
<span class="oh-roll-successes">${successes} ${game.i18n.localize("OATHHAMMER.Roll.Damage")}</span>
${apNote}
</div>
</div>
`
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 ? `<div class="oh-roll-mods">${modParts.join(" · ")}</div>` : ""
const stressLine = `<div class="oh-stress-line${isBlocked ? " stress-blocked" : ""}">
🧠 ${game.i18n.localize("OATHHAMMER.Label.ArcaneStress")}: +${totalStressGain}
(${onesCount} × 1s + ${stressCost} enh.) → <strong>${newStress}/${stressMax}</strong>
${isBlocked ? `${game.i18n.localize("OATHHAMMER.Label.StressBlocked")}` : ""}
</div>`
const content = `
<div class="oh-roll-card oh-spell-card">
<div class="oh-roll-header">
<img src="${spell.img}" class="oh-card-weapon-img" alt="${spell.name}" />
<span>${spell.name} (DV ${dv})</span>
</div>
<div class="oh-roll-info">
<span>${attrLabel} ${intRank} + ${skillLabel} ${magicRank}</span>
<span>${colorEmoji} ${totalDice}d6 (${threshold}+)</span>
</div>
${modLine}
<div class="oh-roll-dice">${diceHtml}</div>
<div class="oh-roll-result ${resultClass}">
<span class="oh-roll-successes">${successes} / ${dv}</span>
<span class="oh-roll-verdict">${resultLabel}</span>
</div>
${stressLine}
</div>
`
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 ? `<div class="oh-roll-mods">${modParts.join(" · ")}</div>` : ""
const blockedLine = !isSuccess
? `<div class="oh-miracle-blocked">⚠ ${game.i18n.localize("OATHHAMMER.Roll.MiracleBlocked")}</div>`
: ""
const dvNote = isRitual
? `DV ${dv} (${game.i18n.localize("OATHHAMMER.Label.Ritual")})`
: `DV ${dv}`
const content = `
<div class="oh-roll-card oh-miracle-card">
<div class="oh-roll-header">
<img src="${miracle.img}" class="oh-card-weapon-img" alt="${miracle.name}" />
<span>${miracle.name} (${dvNote})</span>
</div>
<div class="oh-roll-info">
<span>${attrLabel} ${wpRank} + ${skillLabel} ${magicRank}</span>
<span>${colorEmoji} ${totalDice}d6 (${threshold}+)</span>
</div>
${modLine}
<div class="oh-roll-dice">${diceHtml}</div>
<div class="oh-roll-result ${resultClass}">
<span class="oh-roll-successes">${successes} / ${dv}</span>
<span class="oh-roll-verdict">${resultLabel}</span>
</div>
${blockedLine}
</div>
`
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 ? `<div class="oh-roll-mods">${modParts.join(" · ")}</div>` : ""
const content = `
<div class="oh-roll-card oh-defense-card">
<div class="oh-roll-header">
<i class="fa-solid fa-shield-halved oh-defense-icon"></i>
<span>${game.i18n.localize("OATHHAMMER.Roll.Defense")}${typeLabel}</span>
</div>
<div class="oh-roll-info">
<span>${attrLabel} ${attrRank} + ${skillLabel} ${defRank}</span>
<span>${colorEmoji} ${totalDice}d6 (${threshold}+)</span>
</div>
${modLine}
<div class="oh-roll-dice">${diceHtml}</div>
<div class="oh-roll-result">
<span class="oh-roll-successes">${successes} ${game.i18n.localize("OATHHAMMER.Roll.Successes")}</span>
<span class="oh-roll-verdict">${game.i18n.localize("OATHHAMMER.Roll.DefenseResult")}</span>
</div>
</div>
`
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 ? `<div class="oh-roll-mods">${modParts.join(" · ")}</div>` : ""
const content = `
<div class="oh-roll-card oh-defense-card">
<div class="oh-roll-header">
<img src="${weapon.img}" class="oh-card-weapon-img" alt="${weapon.name}" />
<span>${weapon.name}${game.i18n.localize("OATHHAMMER.Roll.Defense")} (${typeLabel})</span>
</div>
<div class="oh-roll-info">
<span>${attrLabel} ${attrRank} + ${skillLabel} ${defRank}</span>
<span>${colorEmoji} ${totalDice}d6 (${threshold}+)</span>
</div>
${modLine}
<div class="oh-roll-dice">${diceHtml}</div>
<div class="oh-roll-result">
<span class="oh-roll-successes">${successes} ${game.i18n.localize("OATHHAMMER.Roll.Successes")}</span>
<span class="oh-roll-verdict">${game.i18n.localize("OATHHAMMER.Roll.DefenseResult")}</span>
</div>
</div>
`
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 = `<em>${game.i18n.localize("OATHHAMMER.Roll.ArmorBypassed")}</em>`
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 ? `<div class="oh-roll-mods">${modParts.join(" · ")}</div>` : ""
const content = `
<div class="oh-roll-card oh-armor-card">
<div class="oh-roll-header">
<img src="${armor.img}" class="oh-card-weapon-img" alt="${armor.name}" />
<span>${armor.name} (AV ${av})</span>
</div>
<div class="oh-roll-info">
<span>${game.i18n.localize("OATHHAMMER.Roll.ArmorRoll")}</span>
<span>${colorEmoji} ${totalDice}d6 (${threshold}+)</span>
</div>
${modLine}
<div class="oh-roll-dice">${diceHtml}</div>
<div class="oh-roll-result ${successes > 0 ? "roll-success" : ""}">
<span class="oh-roll-successes">${successes} ${game.i18n.localize("OATHHAMMER.Roll.Successes")}</span>
<span class="oh-roll-verdict">${successes} ${game.i18n.localize("OATHHAMMER.Roll.Damage")}</span>
</div>
</div>
`
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 ? `<div class="oh-roll-mods">${modParts.join(" · ")}</div>` : ""
const content = `
<div class="oh-roll-card">
<div class="oh-roll-header">⚔ ${game.i18n.localize("OATHHAMMER.Roll.Initiative")}${actor.name}</div>
<div class="oh-roll-info">
<span>${game.i18n.localize("OATHHAMMER.Attribute.Fate")} ${fateRank}${initBonus ? ` + ${initBonus}` : ""}</span>
<span>⬜ ${pool}d6 (4+)</span>
</div>
${modLine}
<div class="oh-roll-dice">${diceHtml}</div>
<div class="oh-roll-result roll-opposed">
<span class="oh-roll-successes">${successes}</span>
<span class="oh-roll-verdict">${game.i18n.localize("OATHHAMMER.Roll.Opposed")}</span>
</div>
</div>
`
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 ? `<div class="oh-roll-mods">${modParts.join(" · ")}</div>` : ""
const content = `
<div class="oh-roll-card">
<div class="oh-roll-header">
<img src="${skillItem.img}" class="oh-card-weapon-img" alt="${skillItem.name}" />
<span>${skillItem.name}${actor.name}</span>
</div>
<div class="oh-roll-info">
<span>${colorEmoji} ${totalDice}d6 (${threshold}+)</span>
</div>
${modLine}
<div class="oh-roll-dice">${diceHtml}</div>
<div class="oh-roll-result">
<span class="oh-roll-successes">${successes} ${game.i18n.localize("OATHHAMMER.Roll.Successes")}</span>
</div>
</div>
`
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 ? `<div class="oh-roll-mods">${modParts.join(" · ")}</div>` : ""
const content = `
<div class="oh-roll-card oh-weapon-card">
<div class="oh-roll-header">
<img src="${weapon.img}" class="oh-card-weapon-img" alt="${weapon.name}" />
<span>${weapon.name}${game.i18n.localize("OATHHAMMER.Dialog.Attack")} (${actor.name})</span>
</div>
<div class="oh-roll-info">
<span>${colorEmoji} ${totalDice}d6 (${threshold}+)</span>
</div>
${modLine}
<div class="oh-roll-dice">${diceHtml}</div>
<div class="oh-roll-result">
<span class="oh-roll-successes">${successes} ${game.i18n.localize("OATHHAMMER.Roll.Successes")}</span>
</div>
</div>
`
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 ? `<div class="oh-roll-mods">${modParts.join(" · ")}</div>` : ""
const content = `
<div class="oh-roll-card oh-weapon-card">
<div class="oh-roll-header">
<img src="${weapon.img}" class="oh-card-weapon-img" alt="${weapon.name}" />
<span>${weapon.name}${game.i18n.localize("OATHHAMMER.Dialog.Damage")} (${actor.name})</span>
</div>
<div class="oh-roll-info">
<span>${colorEmoji} ${totalDice}d6 (${threshold}+)</span>
<span>${game.i18n.localize("OATHHAMMER.Label.Damage")}: ${weapon.system.damageLabel}</span>
</div>
${modLine}
<div class="oh-roll-dice">${diceHtml}</div>
<div class="oh-roll-result">
<span class="oh-roll-successes">${successes} ${game.i18n.localize("OATHHAMMER.Roll.Successes")}</span>
</div>
</div>
`
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 ? `<div class="oh-roll-mods">${modParts.join(" · ")}</div>` : ""
const label = game.i18n.localize("OATHHAMMER.Label.ArmorDice")
const content = `
<div class="oh-roll-card oh-armor-card">
<div class="oh-roll-header">
<img src="${actor.img}" class="oh-card-weapon-img" alt="${actor.name}" />
<span>${label}${actor.name}</span>
</div>
<div class="oh-roll-info">
<span>${colorEmoji} ${totalDice}d6 (${threshold}+)</span>
</div>
${modLine}
<div class="oh-roll-dice">${diceHtml}</div>
<div class="oh-roll-result ${successes > 0 ? "roll-success" : ""}">
<span class="oh-roll-successes">${successes} ${game.i18n.localize("OATHHAMMER.Roll.Successes")}</span>
<span class="oh-roll-verdict">${successes} ${game.i18n.localize("OATHHAMMER.Roll.Damage")}</span>
</div>
</div>
`
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 ? `<div class="oh-roll-mods">${modParts.join(" · ")}</div>` : ""
const resultClass = isSuccess ? "roll-success" : "roll-failure"
const resultLabel = isSuccess
? game.i18n.localize("OATHHAMMER.Roll.Success")
: game.i18n.localize("OATHHAMMER.Roll.Failure")
const content = `
<div class="oh-roll-card oh-spell-card">
<div class="oh-roll-header">
<img src="${spell.img}" class="oh-card-weapon-img" alt="${spell.name}" />
<span>${spell.name} (DV ${dv}) — ${actor.name}</span>
</div>
<div class="oh-roll-info">
<span>${colorEmoji} ${totalDice}d6 (${threshold}+)</span>
</div>
${modLine}
<div class="oh-roll-dice">${diceHtml}</div>
<div class="oh-roll-result ${resultClass}">
<span class="oh-roll-successes">${successes} / ${dv}</span>
<span class="oh-roll-verdict">${resultLabel}</span>
</div>
</div>
`
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 ? `<div class="oh-roll-mods">${modParts.join(" · ")}</div>` : ""
const resultClass = isSuccess ? "roll-success" : "roll-failure"
const resultLabel = isSuccess
? game.i18n.localize("OATHHAMMER.Roll.Success")
: game.i18n.localize("OATHHAMMER.Roll.Failure")
const content = `
<div class="oh-roll-card oh-miracle-card">
<div class="oh-roll-header">
<img src="${miracle.img}" class="oh-card-weapon-img" alt="${miracle.name}" />
<span>${miracle.name}${actor.name}</span>
</div>
<div class="oh-roll-info">
<span>${colorEmoji} ${totalDice}d6 (${threshold}+)</span>
</div>
${modLine}
<div class="oh-roll-dice">${diceHtml}</div>
<div class="oh-roll-result ${resultClass}">
<span class="oh-roll-successes">${successes} / ${dv}</span>
<span class="oh-roll-verdict">${resultLabel}</span>
</div>
</div>
`
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 ? `<div class="oh-roll-mods">${modParts.join(" · ")}</div>` : ""
const content = `
<div class="oh-roll-card oh-weapon-card">
<div class="oh-roll-header">
<img src="${attack.img}" class="oh-card-weapon-img" alt="${attack.name}" />
<span>${attack.name}${game.i18n.localize("OATHHAMMER.Dialog.Damage")} (${actor.name})</span>
</div>
<div class="oh-roll-info">
<span>${colorEmoji} ${totalDice}d6 (${threshold}+)</span>
${ap > 0 ? `<span>AP ${ap}</span>` : ""}
</div>
${modLine}
<div class="oh-roll-dice">${diceHtml}</div>
<div class="oh-roll-result">
<span class="oh-roll-successes">${successes} ${game.i18n.localize("OATHHAMMER.Roll.Successes")}</span>
</div>
</div>
`
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 }
}