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, supporters = 0, attrOverride, visibility, flavor } = 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 // Total dice pool (never below 1) const totalDice = Math.max(attrRank + skillRank + skillMod + bonus + (luckSpend * 2) + 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() // Count successes — exploding 6s produce additional dice 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 === 6) extraDice++ diceResults.push({ val, exploded: false }) } while (extraDice > 0) { const xRoll = await new Roll(`${extraDice}d6`).evaluate() extraDice = 0 for (const r of xRoll.dice[0].results) { const val = r.result if (val >= threshold) successes++ if (val === 6) extraDice++ diceResults.push({ val, exploded: true }) } } const isSuccess = 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 modParts = [] if (bonus !== 0) modParts.push(`${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}`) if (luckSpend > 0) modParts.push(`+${luckSpend * 2} ${game.i18n.localize("OATHHAMMER.Dialog.LuckSpend")} (${luckSpend}LP)`) if (supporters > 0) modParts.push(`+${supporters} ${game.i18n.localize("OATHHAMMER.Dialog.Supporters")}`) 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 cardFlavor = flavor ?? `${skillLabel} ${game.i18n.localize("OATHHAMMER.Roll.Check")} (DV ${dv})` const content = `
${cardFlavor}
${attrLabel} ${attrRank} + ${skillLabel} ${skillRank} ${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: [roll], 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}>} */ async function _rollPool(pool, threshold) { const roll = await new Roll(`${Math.max(pool, 1)}d6`).evaluate() 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 === 6) extraDice++ diceResults.push({ val, exploded: false }) } while (extraDice > 0) { const xRoll = await new Roll(`${extraDice}d6`).evaluate() extraDice = 0 for (const r of xRoll.dice[0].results) { const val = r.result if (val >= threshold) successes++ if (val === 6) extraDice++ diceResults.push({ val, exploded: true }) } } return { roll, successes, diceResults } } /** * Render dice results as HTML spans. */ 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, visibility, autoAttackBonus = 0 } = options const sys = weapon.system const actorSys = actor.system const isRanged = !sys.usesMight && (sys.shortRange > 0 || sys.longRange > 0) const skillKey = 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 colorType = actorSys.skills[skillKey].colorDiceType ?? "white" const threshold = colorType === "black" ? 2 : colorType === "red" ? 3 : 4 const colorEmoji = colorType === "black" ? "⬛" : colorType === "red" ? "🔴" : "⬜" const totalDice = Math.max(attrRank + skillRank + attackBonus + rangeCondition + autoAttackBonus, 1) const { roll, successes, diceResults } = await _rollPool(totalDice, threshold) 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) // Modifier summary 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`) 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: [roll], 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 mightRank = actorSys.attributes.might.rank const baseDamageDice = sys.usesMight ? Math.max(mightRank + sys.damageMod, 1) : Math.max(sys.damageMod, 1) const totalDice = Math.max(baseDamageDice + sv + damageBonus + autoDamageBonus, 1) const { roll, 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 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: [roll], 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, grimPenalty = 0, visibility, } = options const sys = spell.system const actorSys = actor.system const intRank = actorSys.attributes.intelligence.rank const magicRank = actorSys.skills.magic.rank const totalDice = Math.max(intRank + magicRank + bonus + poolPenalty + elementalBonus + grimPenalty, 1) const threshold = redDice ? 3 : 4 const colorEmoji = redDice ? "🔴" : "⬜" const { roll, successes, diceResults } = await _rollPool(totalDice, threshold) 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 }) } 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 (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")}`) 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: [roll], 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, } = options const sys = miracle.system const actorSys = actor.system const wpRank = actorSys.attributes.willpower.rank const magicRank = actorSys.skills.magic.rank const totalDice = Math.max(wpRank + magicRank + bonus, 1) const threshold = 4 const colorEmoji = "⬜" const { roll, successes, diceResults } = await _rollPool(totalDice, threshold) const diceHtml = _diceHtml(diceResults, threshold) const isSuccess = successes >= dv 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 modLine = bonus !== 0 ? `
${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}
` : "" 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: [roll], sound: CONFIG.sounds.dice, } ChatMessage.applyRollMode(msgData, rollMode) await ChatMessage.create(msgData) return { successes, dv, isSuccess } } function _cap(s) { return s ? s.charAt(0).toUpperCase() + s.slice(1) : "" }