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

556 lines
22 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, 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 `<span class="oh-die ${cssClass}${explodedClass}" title="${exploded ? "💥 Exploded" : ""}">${val}</span>`
}).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 ? `<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 cardFlavor = flavor ?? `${skillLabel} ${game.i18n.localize("OATHHAMMER.Roll.Check")} (DV ${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">${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: [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 = `
<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}>}
*/
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 `<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, 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 ? `<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: [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 ? `<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: [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 ? `<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: [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
? `<div class="oh-roll-mods">${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}</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: [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) : "" }