Add roll windows from actor sheet

This commit is contained in:
2026-03-15 23:20:32 +01:00
parent 82fddb0cb3
commit 49347370c7
57 changed files with 6372 additions and 184 deletions

View File

@@ -9,6 +9,9 @@ export { default as OathHammerMiracleSheet } from "./sheets/miracle-sheet.mjs"
export { default as OathHammerMagicItemSheet } from "./sheets/magic-item-sheet.mjs"
export { default as OathHammerTraitSheet } from "./sheets/trait-sheet.mjs"
export { default as OathHammerOathSheet } from "./sheets/oath-sheet.mjs"
export { default as OathHammerLineageSheet } from "./sheets/lineage-sheet.mjs"
export { default as OathHammerClassSheet } from "./sheets/class-sheet.mjs"
export { default as OathHammerBuildingSheet } from "./sheets/building-sheet.mjs"
export { default as OathHammerRollDialog } from "./roll-dialog.mjs"
export { default as OathHammerWeaponDialog } from "./weapon-dialog.mjs"
export { default as OathHammerSpellDialog } from "./spell-dialog.mjs"
export { default as OathHammerMiracleDialog } from "./miracle-dialog.mjs"

View File

@@ -0,0 +1,89 @@
import { SYSTEM } from "../config/system.mjs"
export default class OathHammerMiracleDialog {
static async prompt(actor, miracle) {
const sys = miracle.system
const actorSys = actor.system
const wpRank = actorSys.attributes.willpower.rank
const magicRank = actorSys.skills.magic.rank
const basePool = wpRank + magicRank
const isRitual = sys.isRitual
const dv = isRitual ? (sys.difficultyValue || 1) : null
const traditionLabel = (() => {
const key = SYSTEM.DIVINE_TRADITIONS?.[sys.divineTradition]
return key ? game.i18n.localize(key) : sys.divineTradition
})()
// Miracle count options — DV = miracle number today
const miracleCountOptions = Array.from({ length: 10 }, (_, i) => ({
value: i + 1,
label: `${i + 1}${_ordinal(i + 1)} — DV${i + 1}`,
selected: i === 0,
}))
const bonusOptions = Array.from({ length: 13 }, (_, i) => {
const v = i - 6
return { value: v, label: v > 0 ? `+${v}` : String(v), selected: v === 0 }
})
const rollModes = foundry.utils.duplicate(CONFIG.Dice.rollModes)
const context = {
actorName: actor.name,
miracleName: miracle.name,
miracleImg: miracle.img,
dv,
traditionLabel,
isRitual,
range: sys.range,
duration: sys.duration,
spellSave: sys.spellSave,
wpRank,
magicRank,
basePool,
miracleCountOptions,
bonusOptions,
rollModes,
visibility: game.settings.get("core", "rollMode"),
}
const content = await foundry.applications.handlebars.renderTemplate(
"systems/fvtt-oath-hammer/templates/miracle-cast-dialog.hbs",
context
)
const result = await foundry.applications.api.DialogV2.wait({
window: { title: game.i18n.format("OATHHAMMER.Dialog.MiracleCastTitle", { miracle: miracle.name }) },
classes: ["fvtt-oath-hammer"],
content,
rejectClose: false,
buttons: [{
label: game.i18n.localize("OATHHAMMER.Dialog.InvokeMiracle"),
callback: (_ev, btn) => Object.fromEntries(
[...btn.form.elements].filter(e => e.name).map(e => [e.name, e.value])
),
}],
})
if (!result) return null
const computedDV = isRitual ? dv : (parseInt(result.miracleCount) || 1)
return {
dv: computedDV,
isRitual,
bonus: parseInt(result.bonus) || 0,
visibility: result.visibility ?? game.settings.get("core", "rollMode"),
}
}
}
function _ordinal(n) {
const s = ["th", "st", "nd", "rd"]
const v = n % 100
return s[(v - 20) % 10] ?? s[v] ?? s[0]
}

View File

@@ -0,0 +1,154 @@
import { SYSTEM } from "../config/system.mjs"
/**
* Roll configuration dialog for Oath Hammer skill checks.
* Uses DialogV2.wait() — pattern from fvtt-hellborn / fvtt-lethal-fantasy.
*
* Dice rules (from rulebook):
* 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).
* Luck Points: spending 1 LP adds +2 dice; LP restored each session.
* Supporters: each ally with ranks in the skill adds +1 die.
*/
export default class OathHammerRollDialog {
/**
* Dual-attribute skills: show an alternate attribute option in the dialog.
* key → { primary, alt, altLabel i18n key }
*/
static DUAL_ATTRIBUTE_SKILLS = {
defense: { alt: "might", altLabelKey: "OATHHAMMER.Roll.DualAttr.DefenseMelee" },
fighting: { alt: "agility", altLabelKey: "OATHHAMMER.Roll.DualAttr.FightingNimble" },
magic: { alt: "intelligence", altLabelKey: "OATHHAMMER.Roll.DualAttr.MagicSpells" },
}
/**
* Show a skill check dialog and return the user's choices.
*
* @param {Actor} actor Actor performing the check
* @param {string} skillKey SYSTEM.SKILLS key (e.g. "fortune", "fighting")
* @returns {Promise<{dv, bonus, luckSpend, supporters, attrOverride, visibility}|null>}
* Resolved options, or null if the dialog was cancelled.
*/
static async prompt(actor, skillKey) {
const sys = actor.system
const skillDef = SYSTEM.SKILLS[skillKey]
if (!skillDef) throw new Error(`Unknown skill: ${skillKey}`)
const defaultAttrKey = skillDef.attribute
const attrRank = sys.attributes[defaultAttrKey].rank
const skill = sys.skills[skillKey]
const skillRank = skill.rank
const skillMod = skill.modifier ?? 0
const baseTotal = attrRank + skillRank + skillMod
const colorType = skill.colorDiceType ?? "white"
const threshold = colorType === "black" ? 2 : colorType === "red" ? 3 : 4
const colorLabel = colorType === "black" ? "⬛" : colorType === "red" ? "🔴" : "⬜"
const dualDef = this.DUAL_ATTRIBUTE_SKILLS[skillKey]
// Build attribute options for dual-attribute skills
let attrOptions = null
if (dualDef) {
const altRank = sys.attributes[dualDef.alt].rank
attrOptions = [
{
value: defaultAttrKey,
label: `${game.i18n.localize(`OATHHAMMER.Attribute.${defaultAttrKey.charAt(0).toUpperCase()}${defaultAttrKey.slice(1)}`)} (${attrRank}) — default`,
selected: true,
},
{
value: dualDef.alt,
label: `${game.i18n.localize(`OATHHAMMER.Attribute.${dualDef.alt.charAt(0).toUpperCase()}${dualDef.alt.slice(1)}`)} (${altRank}) — ${game.i18n.localize(dualDef.altLabelKey)}`,
selected: false,
},
]
}
const availableLuck = sys.luck?.value ?? 0
const rollModes = foundry.utils.duplicate(CONFIG.Dice.rollModes)
// Build select option arrays
const dvOptions = Array.from({ length: 10 }, (_, i) => {
const v = i + 1
return { value: v, label: String(v), selected: v === 2 }
})
const bonusOptions = Array.from({ length: 13 }, (_, i) => {
const v = i - 6
return { value: v, label: v > 0 ? `+${v}` : String(v), selected: v === 0 }
})
const supportersOptions = Array.from({ length: 7 }, (_, i) => ({
value: i, label: String(i), selected: i === 0,
}))
const luckOptions = Array.from({ length: availableLuck + 1 }, (_, i) => ({
value: i,
label: i === 0 ? `0` : `${i} (+${i * 2}d)`,
selected: i === 0,
}))
const context = {
actorName: actor.name,
skillKey,
skillLabel: game.i18n.localize(skillDef.label),
attrKey: defaultAttrKey,
attrLabel: game.i18n.localize(`OATHHAMMER.Attribute.${defaultAttrKey.charAt(0).toUpperCase()}${defaultAttrKey.slice(1)}`),
attrRank,
skillRank,
skillMod,
baseTotal,
colorType,
colorLabel,
threshold,
availableLuck,
attrOptions,
isDualAttr: !!dualDef,
rollModes,
visibility: game.settings.get("core", "rollMode"),
dvOptions,
bonusOptions,
supportersOptions,
luckOptions,
}
const content = await foundry.applications.handlebars.renderTemplate(
"systems/fvtt-oath-hammer/templates/roll-dialog.hbs",
context
)
const title = game.i18n.format("OATHHAMMER.Dialog.SkillCheckTitle", { skill: context.skillLabel })
const result = await foundry.applications.api.DialogV2.wait({
window: { title },
classes: ["fvtt-oath-hammer"],
content,
rejectClose: false,
buttons: [
{
label: game.i18n.localize("OATHHAMMER.Dialog.Roll"),
callback: (_event, button) => {
const out = {}
for (const el of button.form.elements) {
if (el.name) out[el.name] = el.value
}
return out
},
},
],
})
if (!result) return null
const attrOverride = result.attrOverride || defaultAttrKey
return {
dv: Math.max(1, parseInt(result.dv) || 2),
bonus: parseInt(result.bonus) || 0,
luckSpend: Math.min(Math.max(0, parseInt(result.luckSpend) || 0), availableLuck),
supporters: Math.max(0, parseInt(result.supporters) || 0),
attrOverride,
visibility: result.visibility ?? game.settings.get("core", "rollMode"),
}
}
}

View File

@@ -95,8 +95,8 @@ export default class OathHammerActorSheet extends HandlebarsApplicationMixin(fou
async _onDropItem(item) {
const itemData = item.toObject()
// Lineage and class are unique: replace any existing item of the same type
if (item.type === "lineage" || item.type === "class") {
// Class is unique: replace any existing item of the same type
if (item.type === "class") {
const existing = this.document.itemTypes[item.type]
if (existing.length > 0) {
await this.document.deleteEmbeddedDocuments("Item", existing.map(i => i.id))

View File

@@ -1,5 +1,6 @@
const { HandlebarsApplicationMixin } = foundry.applications.api
import { ARMOR_TYPE_CHOICES, WEAPON_PROFICIENCY_GROUPS } from "../../config/system.mjs"
import { rollRarityCheck } from "../../rolls.mjs"
export default class OathHammerItemSheet extends HandlebarsApplicationMixin(foundry.applications.sheets.ItemSheetV2) {
static SHEET_MODES = { EDIT: 0, PLAY: 1 }
@@ -28,6 +29,7 @@ export default class OathHammerItemSheet extends HandlebarsApplicationMixin(foun
actions: {
toggleSheet: OathHammerItemSheet.#onToggleSheet,
editImage: OathHammerItemSheet.#onEditImage,
rollRarity: OathHammerItemSheet.#onRollRarity,
},
}
@@ -134,4 +136,20 @@ export default class OathHammerItemSheet extends HandlebarsApplicationMixin(foun
})
return fp.browse()
}
static async #onRollRarity(event, target) {
const rarity = this.document.system.rarity
if (!rarity) return
// Find the owning actor (embedded item) or prompt user to select a character
let actor = this.document.parent
if (!actor || actor.documentName !== "Actor") {
// Item not embedded — use the user's selected character
actor = game.user.character
if (!actor) {
ui.notifications.warn(game.i18n.localize("OATHHAMMER.Roll.NoActor"))
return
}
}
await rollRarityCheck(actor, rarity, this.document.name)
}
}

View File

@@ -1,5 +1,10 @@
import OathHammerActorSheet from "./base-actor-sheet.mjs"
import { SYSTEM } from "../../config/system.mjs"
import OathHammerRollDialog from "../roll-dialog.mjs"
import OathHammerWeaponDialog from "../weapon-dialog.mjs"
import OathHammerSpellDialog from "../spell-dialog.mjs"
import OathHammerMiracleDialog from "../miracle-dialog.mjs"
import { rollSkillCheck, rollWeaponAttack, rollWeaponDamage, rollSpellCast, rollMiracleCast } from "../../rolls.mjs"
export default class OathHammerCharacterSheet extends OathHammerActorSheet {
/** @override */
@@ -13,10 +18,15 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet {
contentClasses: ["character-content"],
},
actions: {
createWeapon: OathHammerCharacterSheet.#onCreateWeapon,
createSpell: OathHammerCharacterSheet.#onCreateSpell,
createMiracle: OathHammerCharacterSheet.#onCreateMiracle,
createWeapon: OathHammerCharacterSheet.#onCreateWeapon,
createSpell: OathHammerCharacterSheet.#onCreateSpell,
createMiracle: OathHammerCharacterSheet.#onCreateMiracle,
createEquipment: OathHammerCharacterSheet.#onCreateEquipment,
rollSkill: OathHammerCharacterSheet.#onRollSkill,
attackWeapon: OathHammerCharacterSheet.#onAttackWeapon,
damageWeapon: OathHammerCharacterSheet.#onDamageWeapon,
castSpell: OathHammerCharacterSheet.#onCastSpell,
castMiracle: OathHammerCharacterSheet.#onCastMiracle,
},
}
@@ -57,9 +67,8 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet {
async _prepareContext() {
const context = await super._prepareContext()
context.tabs = this.#getTabs()
// lineage/class/experience available to all parts (header + identity tab)
// class/experience available to all parts (header + identity tab)
const doc = this.document
context.lineage = doc.itemTypes.lineage?.[0] ?? null
context.characterClass = doc.itemTypes["class"]?.[0] ?? null
return context
}
@@ -236,4 +245,52 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet {
static #onCreateEquipment(event, target) {
this.document.createEmbeddedDocuments("Item", [{ name: game.i18n.localize("OATHHAMMER.NewItem.Equipment"), type: "equipment" }])
}
static async #onRollSkill(event, target) {
const skillKey = target.dataset.skill
if (!skillKey) return
const result = await OathHammerRollDialog.prompt(this.document, skillKey)
if (!result) return
await rollSkillCheck(this.document, skillKey, result.dv, result)
}
static async #onAttackWeapon(event, target) {
const weaponId = target.dataset.itemId
if (!weaponId) return
const weapon = this.document.items.get(weaponId)
if (!weapon) return
const opts = await OathHammerWeaponDialog.promptAttack(this.document, weapon)
if (!opts) return
await rollWeaponAttack(this.document, weapon, opts)
}
static async #onDamageWeapon(event, target) {
const weaponId = target.dataset.itemId
if (!weaponId) return
const weapon = this.document.items.get(weaponId)
if (!weapon) return
const opts = await OathHammerWeaponDialog.promptDamage(this.document, weapon, 0)
if (!opts) return
await rollWeaponDamage(this.document, weapon, opts)
}
static async #onCastSpell(event, target) {
const spellId = target.dataset.itemId
if (!spellId) return
const spell = this.document.items.get(spellId)
if (!spell) return
const opts = await OathHammerSpellDialog.prompt(this.document, spell)
if (!opts) return
await rollSpellCast(this.document, spell, opts)
}
static async #onCastMiracle(event, target) {
const miracleId = target.dataset.itemId
if (!miracleId) return
const miracle = this.document.items.get(miracleId)
if (!miracle) return
const opts = await OathHammerMiracleDialog.prompt(this.document, miracle)
if (!opts) return
await rollMiracleCast(this.document, miracle, opts)
}
}

View File

@@ -1,30 +0,0 @@
import OathHammerItemSheet from "./base-item-sheet.mjs"
export default class OathHammerLineageSheet extends OathHammerItemSheet {
/** @override */
static DEFAULT_OPTIONS = {
classes: ["lineage"],
position: {
width: 640,
},
window: {
contentClasses: ["lineage-content"],
},
}
/** @override */
static PARTS = {
main: {
template: "systems/fvtt-oath-hammer/templates/item/lineage-sheet.hbs",
},
}
/** @override */
async _prepareContext() {
const context = await super._prepareContext()
context.enrichedTraits = await foundry.applications.ux.TextEditor.implementation.enrichHTML(
this.document.system.traits ?? "", { async: true }
)
return context
}
}

View File

@@ -0,0 +1,118 @@
import { SYSTEM } from "../config/system.mjs"
/**
* Spell enhancements — applied before casting (p.96-97).
* stress: Arcane Stress cost regardless of roll result
* penalty: dice pool penalty
* redDice: true = roll red dice (3+ threshold) on the check
* noStress: true = 1s rolled do NOT add Arcane Stress (Safe Spell)
*/
export const SPELL_ENHANCEMENTS = {
none: { label: "OATHHAMMER.Enhancement.None", stress: 0, penalty: 0, redDice: false, noStress: false },
focused: { label: "OATHHAMMER.Enhancement.Focused", stress: 1, penalty: 0, redDice: true, noStress: false },
controlled: { label: "OATHHAMMER.Enhancement.Controlled", stress: 1, penalty: 0, redDice: false, noStress: false },
empowered: { label: "OATHHAMMER.Enhancement.Empowered", stress: 2, penalty: 0, redDice: false, noStress: false },
extended: { label: "OATHHAMMER.Enhancement.Extended", stress: 1, penalty: -1, redDice: false, noStress: false },
penetrating: { label: "OATHHAMMER.Enhancement.Penetrating", stress: 1, penalty: -1, redDice: false, noStress: false },
lethal: { label: "OATHHAMMER.Enhancement.Lethal", stress: 2, penalty: -2, redDice: false, noStress: false },
hastened: { label: "OATHHAMMER.Enhancement.Hastened", stress: 3, penalty: -3, redDice: false, noStress: false },
safe: { label: "OATHHAMMER.Enhancement.Safe", stress: 0, penalty: -3, redDice: false, noStress: true },
}
export default class OathHammerSpellDialog {
static async prompt(actor, spell) {
const sys = spell.system
const actorSys = actor.system
const intRank = actorSys.attributes.intelligence.rank
const magicRank = actorSys.skills.magic.rank
const basePool = intRank + magicRank
const currentStress = actorSys.arcaneStress.value
const stressThreshold = actorSys.arcaneStress.threshold
const isOverThreshold = currentStress >= stressThreshold
const isElemental = sys.tradition === "elemental"
const dv = sys.difficultyValue
const traditionLabel = (() => {
const entry = SYSTEM.SORCEROUS_TRADITIONS?.[sys.tradition]
return entry ? game.i18n.localize(entry.label) : (sys.tradition ?? "")
})()
const enhancementOptions = Object.entries(SPELL_ENHANCEMENTS).map(([key, def]) => ({
value: key,
label: game.i18n.localize(def.label),
selected: key === "none",
}))
const bonusOptions = Array.from({ length: 13 }, (_, i) => {
const v = i - 6
return { value: v, label: v > 0 ? `+${v}` : String(v), selected: v === 0 }
})
const rollModes = foundry.utils.duplicate(CONFIG.Dice.rollModes)
const context = {
actorName: actor.name,
spellName: spell.name,
spellImg: spell.img,
dv,
traditionLabel,
isRitual: sys.isRitual,
isMagicMissile: sys.isMagicMissile,
range: sys.range,
duration: sys.duration,
spellSave: sys.spellSave,
isElemental,
element: sys.element,
intRank,
magicRank,
basePool,
currentStress,
stressThreshold,
isOverThreshold,
enhancementOptions,
bonusOptions,
rollModes,
visibility: game.settings.get("core", "rollMode"),
}
const content = await foundry.applications.handlebars.renderTemplate(
"systems/fvtt-oath-hammer/templates/spell-cast-dialog.hbs",
context
)
const result = await foundry.applications.api.DialogV2.wait({
window: { title: game.i18n.format("OATHHAMMER.Dialog.SpellCastTitle", { spell: spell.name }) },
classes: ["fvtt-oath-hammer"],
content,
rejectClose: false,
buttons: [{
label: game.i18n.localize("OATHHAMMER.Dialog.CastSpell"),
callback: (_ev, btn) => Object.fromEntries(
[...btn.form.elements].filter(e => e.name).map(e => [e.name, e.value])
),
}],
})
if (!result) return null
const enhKey = result.enhancement ?? "none"
const enh = SPELL_ENHANCEMENTS[enhKey] ?? SPELL_ENHANCEMENTS.none
return {
dv,
enhancement: enhKey,
stressCost: enh.stress,
poolPenalty: enh.penalty,
redDice: enh.redDice,
noStress: enh.noStress,
elementalBonus: parseInt(result.elementalBonus) || 0,
bonus: parseInt(result.bonus) || 0,
grimPenalty: parseInt(result.noGrimoire) || 0,
visibility: result.visibility ?? game.settings.get("core", "rollMode"),
}
}
}

View File

@@ -0,0 +1,215 @@
import { SYSTEM } from "../config/system.mjs"
/**
* Roll dialogs for weapon attacks and damage.
*
* Attack flow:
* 1. promptAttack(actor, weapon) → options
* 2. rollWeaponAttack posts a chat card with a "Roll Damage" button
* 3. Clicking the button calls promptDamage with attackSuccesses pre-filled
* 4. rollWeaponDamage posts the damage chat card
*/
export default class OathHammerWeaponDialog {
// ------------------------------------------------------------------ //
// ATTACK DIALOG
// ------------------------------------------------------------------ //
static async promptAttack(actor, weapon) {
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 attrRank = actorSys.attributes[defaultAttr].rank
const skillRank = actorSys.skills[skillKey].rank
const skillColor = actorSys.skills[skillKey].colorDiceType ?? "white"
const threshold = skillColor === "black" ? 2 : skillColor === "red" ? 3 : 4
const hasNimble = sys.traits.has("nimble")
// Auto-bonuses from special properties
let autoAttackBonus = 0
if (sys.specialProperties.has("master-crafted")) autoAttackBonus += 1
if (sys.specialProperties.has("accurate")) autoAttackBonus += 1 // bows
if (sys.specialProperties.has("balanced")) autoAttackBonus += 1 // grants Fast
// Damage info for reference
const hasBrutal = sys.traits.has("brutal")
const hasDeadly = sys.traits.has("deadly")
const damageColorType = hasDeadly ? "black" : hasBrutal ? "red" : "white"
const damageThreshold = hasDeadly ? 2 : hasBrutal ? 3 : 4
const damageColorLabel = hasDeadly ? "⬛" : hasBrutal ? "🔴" : "⬜"
const mightRank = actorSys.attributes.might.rank
const baseDamageDice = sys.usesMight ? Math.max(mightRank + sys.damageMod, 1) : Math.max(sys.damageMod, 1)
const traitLabels = [...sys.traits].map(t => {
const key = SYSTEM.WEAPON_TRAITS[t]
return key ? game.i18n.localize(key) : t
})
// Option arrays
const attackBonusOptions = Array.from({ length: 13 }, (_, i) => {
const v = i - 6
return { value: v, label: v > 0 ? `+${v}` : String(v), selected: v === 0 }
})
const rangeOptions = [
{ value: 0, label: game.i18n.localize("OATHHAMMER.Dialog.RangeNormal") },
{ value: -1, label: game.i18n.localize("OATHHAMMER.Dialog.RangeLong") + " (1)" },
{ value: -2, label: game.i18n.localize("OATHHAMMER.Dialog.RangeMoving") + " (2)" },
{ value: -2, label: game.i18n.localize("OATHHAMMER.Dialog.RangeConcealment") + " (2)" },
{ value: -3, label: game.i18n.localize("OATHHAMMER.Dialog.RangeCover") + " (3)" },
]
const rollModes = foundry.utils.duplicate(CONFIG.Dice.rollModes)
const context = {
actorName: actor.name,
weaponName: weapon.name,
weaponImg: weapon.img,
skillKey,
skillLabel: game.i18n.localize(skillDef.label),
attrKey: defaultAttr,
attrLabel: game.i18n.localize(`OATHHAMMER.Attribute.${_cap(defaultAttr)}`),
attrRank,
skillRank,
colorType: skillColor,
threshold,
baseAttackPool: attrRank + skillRank,
autoAttackBonus,
hasNimble,
mightLabel: game.i18n.localize("OATHHAMMER.Attribute.Might"),
mightRank,
agilityLabel: game.i18n.localize("OATHHAMMER.Attribute.Agility"),
agilityRank: actorSys.attributes.agility.rank,
isRanged,
shortRange: sys.shortRange,
longRange: sys.longRange,
damageLabel: sys.damageLabel,
damageColorType,
damageThreshold,
damageColorLabel,
baseDamageDice,
apValue: sys.ap,
traits: traitLabels,
attackBonusOptions,
rangeOptions,
rollModes,
visibility: game.settings.get("core", "rollMode"),
}
const content = await foundry.applications.handlebars.renderTemplate(
"systems/fvtt-oath-hammer/templates/weapon-attack-dialog.hbs",
context
)
const result = await foundry.applications.api.DialogV2.wait({
window: { title: game.i18n.format("OATHHAMMER.Dialog.AttackTitle", { weapon: weapon.name }) },
classes: ["fvtt-oath-hammer"],
content,
rejectClose: false,
buttons: [{
label: game.i18n.localize("OATHHAMMER.Dialog.RollAttack"),
callback: (_ev, btn) => Object.fromEntries(
[...btn.form.elements].filter(e => e.name).map(e => [e.name, e.value])
),
}],
})
if (!result) return null
return {
attackBonus: parseInt(result.attackBonus) || 0,
rangeCondition: parseInt(result.rangeCondition) || 0,
attrOverride: result.attrOverride || defaultAttr,
visibility: result.visibility ?? game.settings.get("core", "rollMode"),
autoAttackBonus,
}
}
// ------------------------------------------------------------------ //
// DAMAGE DIALOG
// ------------------------------------------------------------------ //
static async promptDamage(actor, weapon, defaultSV = 0) {
const sys = weapon.system
const actorSys = actor.system
const hasBrutal = sys.traits.has("brutal")
const hasDeadly = sys.traits.has("deadly")
const damageColorType = hasDeadly ? "black" : hasBrutal ? "red" : "white"
const damageThreshold = hasDeadly ? 2 : hasBrutal ? 3 : 4
const damageColorLabel = hasDeadly ? "⬛" : hasBrutal ? "🔴" : "⬜"
const mightRank = actorSys.attributes.might.rank
const baseDamageDice = sys.usesMight ? Math.max(mightRank + sys.damageMod, 1) : Math.max(sys.damageMod, 1)
// Auto-bonuses from special properties
let autoDamageBonus = 0
if (sys.specialProperties.has("master-crafted")) autoDamageBonus += 1
if (sys.specialProperties.has("tempered")) autoDamageBonus += 1
if (sys.specialProperties.has("heavy-draw")) autoDamageBonus += 1
const svOptions = Array.from({ length: 11 }, (_, i) => ({
value: i,
label: i === 0 ? "0" : `+${i}d`,
selected: i === defaultSV,
}))
const damageBonusOptions = Array.from({ length: 9 }, (_, i) => {
const v = i - 4
return { value: v, label: v > 0 ? `+${v}` : String(v), selected: v === 0 }
})
const rollModes = foundry.utils.duplicate(CONFIG.Dice.rollModes)
const context = {
actorName: actor.name,
weaponName: weapon.name,
weaponImg: weapon.img,
damageLabel: sys.damageLabel,
damageColorType,
damageThreshold,
damageColorLabel,
baseDamageDice,
autoDamageBonus,
apValue: sys.ap,
defaultSV,
svOptions,
damageBonusOptions,
rollModes,
visibility: game.settings.get("core", "rollMode"),
}
const content = await foundry.applications.handlebars.renderTemplate(
"systems/fvtt-oath-hammer/templates/weapon-damage-dialog.hbs",
context
)
const result = await foundry.applications.api.DialogV2.wait({
window: { title: game.i18n.format("OATHHAMMER.Dialog.DamageTitle", { weapon: weapon.name }) },
classes: ["fvtt-oath-hammer"],
content,
rejectClose: false,
buttons: [{
label: game.i18n.localize("OATHHAMMER.Dialog.RollDamage"),
callback: (_ev, btn) => Object.fromEntries(
[...btn.form.elements].filter(e => e.name).map(e => [e.name, e.value])
),
}],
})
if (!result) return null
return {
sv: parseInt(result.sv) || 0,
damageBonus: parseInt(result.damageBonus) || 0,
visibility: result.visibility ?? game.settings.get("core", "rollMode"),
autoDamageBonus,
}
}
}
function _cap(str) {
return str.charAt(0).toUpperCase() + str.slice(1)
}

View File

@@ -108,6 +108,20 @@ export const WEAPON_TRAITS = {
versatile: "OATHHAMMER.WeaponTrait.Versatile"
}
// Special Properties that can be added to weapons via crafting (p.98)
export const WEAPON_SPECIAL_PROPERTIES = {
accurate: "OATHHAMMER.WeaponProperty.Accurate",
"adv-mechanism":"OATHHAMMER.WeaponProperty.AdvMechanism",
"armor-bane": "OATHHAMMER.WeaponProperty.ArmorBane",
balanced: "OATHHAMMER.WeaponProperty.Balanced",
"heavy-draw": "OATHHAMMER.WeaponProperty.HeavyDraw",
"master-crafted":"OATHHAMMER.WeaponProperty.MasterCrafted",
ornate: "OATHHAMMER.WeaponProperty.Ornate",
refined: "OATHHAMMER.WeaponProperty.Refined",
"rune-etched": "OATHHAMMER.WeaponProperty.RuneEtched",
tempered: "OATHHAMMER.WeaponProperty.Tempered",
}
export const CURRENCY_CHOICES = {
gp: "OATHHAMMER.Currency.GP",
sp: "OATHHAMMER.Currency.SP",
@@ -172,6 +186,16 @@ export const RARITY_CHOICES = {
legendary: "OATHHAMMER.Rarity.Legendary"
}
// Rarity key → Difficulty Value for Fortune rolls
export const RARITY_DV = {
always: 0,
common: 1,
uncommon: 2,
rare: 3,
"very-rare": 4,
legendary: 5
}
// Two types of trait per the rulebook terminology
export const TRAIT_TYPE_CHOICES = {
"special-trait": "OATHHAMMER.TraitType.SpecialTrait",
@@ -350,6 +374,7 @@ export const SYSTEM = {
RUNE_TYPE_CHOICES,
WEAPON_PROFICIENCY_GROUPS,
WEAPON_TRAITS,
WEAPON_SPECIAL_PROPERTIES,
ARMOR_TYPE_CHOICES,
ARMOR_TRAITS,
CURRENCY_CHOICES,
@@ -358,6 +383,7 @@ export const SYSTEM = {
MAGIC_ITEM_TYPE_CHOICES,
MAGIC_QUALITY_CHOICES,
RARITY_CHOICES,
RARITY_DV,
TRAIT_TYPE_CHOICES,
TRAIT_USAGE_PERIOD,
BUILDING_SKILL_CHOICES,

View File

@@ -9,6 +9,5 @@ export { default as OathHammerMiracle } from "./miracle.mjs"
export { default as OathHammerMagicItem } from "./magic-item.mjs"
export { default as OathHammerTrait } from "./trait.mjs"
export { default as OathHammerOath } from "./oath.mjs"
export { default as OathHammerLineage } from "./lineage.mjs"
export { default as OathHammerClass } from "./class.mjs"
export { default as OathHammerBuilding } from "./building.mjs"

View File

@@ -12,7 +12,7 @@ export default class OathHammerArmor extends foundry.abstract.TypeDataModel {
schema.armorType = new fields.StringField({ required: true, initial: "light", choices: SYSTEM.ARMOR_TYPE_CHOICES })
// Armor Value (AV): number of armor dice rolled when receiving damage
schema.armorValue = new fields.NumberField({ ...requiredInteger, initial: 0, min: 0, max: 12 })
schema.armorValue = new fields.NumberField({ ...requiredInteger, initial: 0, min: 0, max: 16 })
// Penalty: modifier to Acrobatics checks AND defense rolls (0, -1, -2, -3…)
schema.penalty = new fields.NumberField({ ...requiredInteger, initial: 0, min: -5, max: 0 })

View File

@@ -23,11 +23,10 @@ export default class OathHammerBuilding extends foundry.abstract.TypeDataModel {
// Monthly tax revenue formula ("3d6", "2d6", "1d3", "" = none)
schema.taxRevenue = new fields.StringField({ required: true, nullable: false, initial: "" })
// Is this building currently constructed in the settlement?
// Is this building currently constructed?
schema.constructed = new fields.BooleanField({ required: true, initial: false })
// Which settlement this building belongs to (free text or settlement name)
schema.settlement = new fields.StringField({ required: true, nullable: false, initial: "" })
// Additional GM notes (special conditions, upgrades, damage, etc.)
schema.notes = new fields.HTMLField({ required: false, textSearch: true })

View File

@@ -9,6 +9,12 @@ export default class OathHammerCharacter extends foundry.abstract.TypeDataModel
schema.description = new fields.HTMLField({ required: true, textSearch: true })
schema.notes = new fields.HTMLField({ required: true, textSearch: true })
// Lineage (simple fields on the actor — not an Item)
schema.lineage = new fields.SchemaField({
name: new fields.StringField({ required: true, nullable: false, initial: "" }),
traits: new fields.HTMLField({ required: true, textSearch: true }),
})
const attributeField = () => new fields.SchemaField({
rank: new fields.NumberField({ ...requiredInteger, initial: 1, min: 1, max: 4 })
})

View File

@@ -1,21 +0,0 @@
export default class OathHammerLineage extends foundry.abstract.TypeDataModel {
static defineSchema() {
const fields = foundry.data.fields
const schema = {}
schema.description = new fields.HTMLField({ required: true, textSearch: true })
// Racial traits and special abilities (rich text)
schema.traits = new fields.HTMLField({ required: true, textSearch: true })
// Base movement speed in feet
schema.movement = new fields.NumberField({ required: true, nullable: false, integer: true, initial: 30, min: 0 })
// Modifier to max Grit Points (e.g. -1 for High Elf, Wood Elf)
schema.gritModifier = new fields.NumberField({ required: true, nullable: false, integer: true, initial: 0 })
return schema
}
static LOCALIZATION_PREFIXES = ["OATHHAMMER.Lineage"]
}

View File

@@ -35,8 +35,7 @@ export default class OathHammerMagicItem extends foundry.abstract.TypeDataModel
})
schema.maxUses = new fields.NumberField({ ...requiredInteger, initial: 0, min: 0 })
// Item slots occupied when carried; 0 = small item (no slots)
schema.slots = new fields.NumberField({ ...requiredInteger, initial: 0, min: 0 })
schema.equipped = new fields.BooleanField({ initial: false })

View File

@@ -17,10 +17,10 @@ export default class OathHammerWeapon extends foundry.abstract.TypeDataModel {
// usesMight=true → formula displayed as "M+2", "M-1", etc.
// usesMight=false → formula displayed as e.g. "6" (fixed dice for bows)
schema.usesMight = new fields.BooleanField({ required: true, initial: true })
schema.damageMod = new fields.NumberField({ ...requiredInteger, initial: 0, min: -4, max: 5 })
schema.damageMod = new fields.NumberField({ ...requiredInteger, initial: 0, min: -4, max: 16 })
// AP (Armor Penetration): penalty imposed on armor/defense rolls
schema.ap = new fields.NumberField({ ...requiredInteger, initial: 0, min: 0, max: 6 })
schema.ap = new fields.NumberField({ ...requiredInteger, initial: 0, min: 0, max: 16 })
// Reach (melee, in ft: 5 / 10 / 15) — ignored for ranged/throwing
schema.reach = new fields.NumberField({ ...requiredInteger, initial: 5, min: 5 })
@@ -35,6 +35,12 @@ export default class OathHammerWeapon extends foundry.abstract.TypeDataModel {
{ required: true, initial: [] }
)
// Special Properties — crafting enhancements (Accurate, Master-Crafted, etc. p.98)
schema.specialProperties = new fields.SetField(
new fields.StringField({ choices: SYSTEM.WEAPON_SPECIAL_PROPERTIES }),
{ required: true, initial: [] }
)
// Item slots (when stowed; 0 = does not occupy slots)
schema.slots = new fields.NumberField({ ...requiredInteger, initial: 1, min: 0 })

555
module/rolls.mjs Normal file
View File

@@ -0,0 +1,555 @@
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) : "" }