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 = `
${cardFlavor}
${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 = `
${itemName ?? game.i18n.localize("OATHHAMMER.Label.Rarity")}
${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 = `
${weapon.name} ${weapon.name} โ€” ${game.i18n.localize("OATHHAMMER.Dialog.Attack")}
${skillLabel} (${attrLabel} ${attrRank}) + ${skillLabel} ${skillRank} ${colorEmoji} ${totalDice}d6 (${threshold}+)
${modLine}
${diceHtml}
${successes} ${game.i18n.localize("OATHHAMMER.Roll.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 = `
${weapon.name} ${weapon.name} โ€” ${game.i18n.localize("OATHHAMMER.Dialog.Damage")}
${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 = `
${spell.name} ${spell.name} (DV ${dv})
${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 = `
${miracle.name} ${miracle.name} (${dvNote})
${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 = `
${game.i18n.localize("OATHHAMMER.Roll.Defense")} โ€” ${typeLabel}
${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 = `
${weapon.name} ${weapon.name} โ€” ${game.i18n.localize("OATHHAMMER.Roll.Defense")} (${typeLabel})
${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 = `
${armor.name} ${armor.name} (AV ${av})
${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.Roll.Initiative")} โ€” ${actor.name}
${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 = `
${skillItem.name} ${skillItem.name} โ€” ${actor.name}
${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 = `
${weapon.name} ${weapon.name} โ€” ${game.i18n.localize("OATHHAMMER.Dialog.Attack")} (${actor.name})
${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 = `
${weapon.name} ${weapon.name} โ€” ${game.i18n.localize("OATHHAMMER.Dialog.Damage")} (${actor.name})
${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 = `
${actor.name} ${label} โ€” ${actor.name}
${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 = `
${spell.name} ${spell.name} (DV ${dv}) โ€” ${actor.name}
${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 = `
${miracle.name} ${miracle.name} โ€” ${actor.name}
${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 = `
${attack.name} ${attack.name} โ€” ${game.i18n.localize("OATHHAMMER.Dialog.Damage")} (${actor.name})
${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 } }