Various fixes and changes based on tester feedback

This commit is contained in:
2026-03-17 13:50:32 +01:00
parent 92ba9c3501
commit 000bf348a6
29 changed files with 1450 additions and 192 deletions

View File

@@ -15,3 +15,5 @@ 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"
export { default as OathHammerDefenseDialog } from "./defense-dialog.mjs"
export { default as OathHammerArmorDialog } from "./armor-dialog.mjs"

View File

@@ -0,0 +1,70 @@
/**
* Armor roll dialog.
*
* Pool = Armor Value (AV) AP penalty + manual bonus (can go to 0, unlike other pools)
* Reinforced trait on the armor → red dice (3+)
* Each success on the roll reduces incoming damage by 1.
*/
export default class OathHammerArmorDialog {
static async prompt(actor, armor) {
const sys = armor.system
const av = sys.armorValue ?? 0
const isReinforced = [...(sys.traits ?? [])].includes("reinforced")
// AP options — entered by the user based on the attacker's weapon
const apOptions = Array.from({ length: 9 }, (_, i) => ({
value: -i,
label: i === 0 ? "0" : `${i}`,
selected: i === 0,
}))
const bonusOptions = Array.from({ length: 7 }, (_, i) => {
const v = i - 3
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,
armorName: armor.name,
armorImg: armor.img,
av,
isReinforced,
apOptions,
bonusOptions,
rollModes,
visibility: game.settings.get("core", "rollMode"),
}
const content = await foundry.applications.handlebars.renderTemplate(
"systems/fvtt-oath-hammer/templates/armor-roll-dialog.hbs",
context
)
const result = await foundry.applications.api.DialogV2.wait({
window: { title: game.i18n.format("OATHHAMMER.Dialog.ArmorRollTitle", { armor: armor.name }) },
classes: ["fvtt-oath-hammer"],
content,
rejectClose: false,
buttons: [{
label: game.i18n.localize("OATHHAMMER.Dialog.RollArmor"),
callback: (_ev, btn) => Object.fromEntries(
[...btn.form.elements].filter(e => e.name).map(e => [e.name, e.value])
),
}],
})
if (!result) return null
return {
av,
isReinforced,
apPenalty: parseInt(result.ap) || 0,
bonus: parseInt(result.bonus) || 0,
visibility: result.visibility ?? game.settings.get("core", "rollMode"),
}
}
}

View File

@@ -0,0 +1,112 @@
/**
* Defense roll dialog.
*
* Pool = governing attribute (Agility default; Might option for melee) + Defense skill
* + armorPenalty (auto from equipped armor, always ≤ 0)
* + parryBonus / blockBonus (from equipped weapon traits)
* + manual bonus
*
* Parry trait on equipped weapon → red dice (3+) vs melee; +1 if two Parry weapons
* Block trait on equipped weapon → red dice (3+) vs ranged; +1 to ranged defense
*/
export default class OathHammerDefenseDialog {
static async prompt(actor) {
const actorSys = actor.system
// ── Attributes & skill ──────────────────────────────────────────────
const agiRank = actorSys.attributes.agility.rank
const mightRank = actorSys.attributes.might.rank
const defRank = actorSys.skills.defense.rank
// ── Equipped weapons ────────────────────────────────────────────────
const equipped = actor.items.filter(i => i.type === "weapon" && i.system.equipped)
const parryCount = equipped.filter(w => w.system.traits?.has?.("parry") || [...(w.system.traits ?? [])].includes("parry")).length
const blockCount = equipped.filter(w => w.system.traits?.has?.("block") || [...(w.system.traits ?? [])].includes("block")).length
// ── Equipped armor penalty (sum) ────────────────────────────────────
const armorPenalty = actor.items
.filter(i => i.type === "armor" && i.system.equipped)
.reduce((sum, a) => sum + (a.system.penalty ?? 0), 0)
// ── Build option lists ───────────────────────────────────────────────
const attackTypeOptions = [
{ value: "melee", label: game.i18n.localize("OATHHAMMER.Dialog.DefenseMelee"), selected: true },
{ value: "ranged", label: game.i18n.localize("OATHHAMMER.Dialog.DefenseRanged"), selected: false },
]
const attrOptions = [
{ value: "agility", label: `${game.i18n.localize("OATHHAMMER.Attribute.Agility")} (${agiRank})`, selected: true },
{ value: "might", label: `${game.i18n.localize("OATHHAMMER.Attribute.Might")} (${mightRank})`, selected: false },
]
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,
agiRank,
mightRank,
defRank,
parryCount,
blockCount,
armorPenalty,
attackTypeOptions,
attrOptions,
bonusOptions,
rollModes,
visibility: game.settings.get("core", "rollMode"),
}
const content = await foundry.applications.handlebars.renderTemplate(
"systems/fvtt-oath-hammer/templates/defense-roll-dialog.hbs",
context
)
const result = await foundry.applications.api.DialogV2.wait({
window: { title: game.i18n.format("OATHHAMMER.Dialog.DefenseTitle", { actor: actor.name }) },
classes: ["fvtt-oath-hammer"],
content,
rejectClose: false,
buttons: [{
label: game.i18n.localize("OATHHAMMER.Dialog.RollDefense"),
callback: (_ev, btn) => Object.fromEntries(
[...btn.form.elements].filter(e => e.name).map(e => [e.name, e.value])
),
}],
})
if (!result) return null
const attackType = result.attackType ?? "melee"
const attrChoice = result.attribute ?? "agility"
const attrRank = attrChoice === "might" ? mightRank : agiRank
const bonus = parseInt(result.bonus) || 0
// Determine red dice and trait bonus from equipped weapons
let redDice = false
let traitBonus = 0
if (attackType === "melee" && parryCount > 0) {
redDice = true
if (parryCount >= 2) traitBonus = 1
} else if (attackType === "ranged" && blockCount > 0) {
redDice = true
traitBonus = 1
}
return {
attackType,
attrRank,
attrChoice,
redDice,
traitBonus,
armorPenalty,
bonus,
visibility: result.visibility ?? game.settings.get("core", "rollMode"),
}
}
}

View File

@@ -68,9 +68,10 @@ export default class OathHammerRollDialog {
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 dvOptions = Array.from({ length: 11 }, (_, i) => {
const v = i // 0..10
const label = v === 0 ? "0 (opposed)" : String(v)
return { value: v, label, selected: v === 2 }
})
const bonusOptions = Array.from({ length: 13 }, (_, i) => {
@@ -130,7 +131,8 @@ export default class OathHammerRollDialog {
callback: (_event, button) => {
const out = {}
for (const el of button.form.elements) {
if (el.name) out[el.name] = el.value
if (!el.name) continue
out[el.name] = el.type === "checkbox" ? String(el.checked) : el.value
}
return out
},
@@ -143,10 +145,11 @@ export default class OathHammerRollDialog {
const attrOverride = result.attrOverride || defaultAttrKey
return {
dv: Math.max(1, parseInt(result.dv) || 2),
dv: Math.max(0, 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),
explodeOn5: result.explodeOn5 === "true",
attrOverride,
visibility: result.visibility ?? game.settings.get("core", "rollMode"),
}

View File

@@ -60,6 +60,16 @@ export default class OathHammerActorSheet extends HandlebarsApplicationMixin(fou
/** @override */
_onRender(context, options) {
this.#dragDrop.forEach((d) => d.bind(this.element))
// ProseMirror "Save" dispatches a change event before committing its .value
// to the element, so FormDataExtended may read stale HTML. Instead we
// intercept the event here, stop it from bubbling to the submitOnChange
// handler, and update the document directly with the current editor value.
for (const pm of this.element.querySelectorAll("prose-mirror[name]")) {
pm.addEventListener("change", async (event) => {
event.stopPropagation()
await this.document.update({ [pm.name]: pm.value ?? "" })
})
}
}
#createDragDropHandlers() {
@@ -89,6 +99,10 @@ export default class OathHammerActorSheet extends HandlebarsApplicationMixin(fou
_onDragStart(event) {
if ("link" in event.target.dataset) return
const li = event.target.closest("[data-item-uuid]")
if (!li) return
const dragData = { type: "Item", uuid: li.dataset.itemUuid }
event.dataTransfer.setData("text/plain", JSON.stringify(dragData))
}
_onDragOver(event) {}

View File

@@ -81,6 +81,12 @@ export default class OathHammerItemSheet extends HandlebarsApplicationMixin(foun
_onRender(context, options) {
super._onRender(context, options)
this.#dragDrop.forEach((d) => d.bind(this.element))
for (const pm of this.element.querySelectorAll("prose-mirror[name]")) {
pm.addEventListener("change", async (event) => {
event.stopPropagation()
await this.document.update({ [pm.name]: pm.value ?? "" })
})
}
}
#createDragDropHandlers() {

View File

@@ -4,7 +4,9 @@ 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"
import OathHammerDefenseDialog from "../defense-dialog.mjs"
import OathHammerArmorDialog from "../armor-dialog.mjs"
import { rollSkillCheck, rollWeaponAttack, rollWeaponDamage, rollSpellCast, rollMiracleCast, rollArmorSave, rollWeaponDefense } from "../../rolls.mjs"
export default class OathHammerCharacterSheet extends OathHammerActorSheet {
/** @override */
@@ -22,11 +24,15 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet {
createSpell: OathHammerCharacterSheet.#onCreateSpell,
createMiracle: OathHammerCharacterSheet.#onCreateMiracle,
createEquipment: OathHammerCharacterSheet.#onCreateEquipment,
createTrait: OathHammerCharacterSheet.#onCreateTrait,
rollSkill: OathHammerCharacterSheet.#onRollSkill,
attackWeapon: OathHammerCharacterSheet.#onAttackWeapon,
defendWeapon: OathHammerCharacterSheet.#onDefendWeapon,
damageWeapon: OathHammerCharacterSheet.#onDamageWeapon,
castSpell: OathHammerCharacterSheet.#onCastSpell,
castMiracle: OathHammerCharacterSheet.#onCastMiracle,
rollArmorSave: OathHammerCharacterSheet.#onRollArmorSave,
resetMiracleBlocked: OathHammerCharacterSheet.#onResetMiracleBlocked,
},
}
@@ -101,6 +107,7 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet {
_violated: o.system.violated
}
})
context.enrichedBackground = await foundry.applications.ux.TextEditor.implementation.enrichHTML(doc.system.background ?? "", { async: true })
break
case "skills": {
context.tab = context.tabs.skills
@@ -170,6 +177,10 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet {
}
})
context.ammunition = doc.itemTypes.ammunition
// Slot tracking: max = 10 + (Might rank × 2); used = sum of all items' slots
context.slotsMax = 10 + (doc.system.attributes.might.rank * 2)
context.slotsUsed = doc.items.reduce((sum, item) => sum + (item.system.slots ?? 0), 0)
context.slotsOver = context.slotsUsed > context.slotsMax
break
case "magic":
context.tab = context.tabs.magic
@@ -180,12 +191,14 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet {
context.tab = context.tabs.equipment
context.equipment = doc.itemTypes.equipment
context.magicItems = doc.itemTypes["magic-item"]
context.slotsMax = 10 + (doc.system.attributes.might.rank * 2)
context.slotsUsed = doc.items.reduce((sum, item) => sum + (item.system.slots ?? 0), 0)
context.slotsOver = context.slotsUsed > context.slotsMax
break
case "notes":
context.tab = context.tabs.notes
context.enrichedBackground = await foundry.applications.ux.TextEditor.implementation.enrichHTML(doc.system.background, { async: true })
context.enrichedDescription = await foundry.applications.ux.TextEditor.implementation.enrichHTML(doc.system.description, { async: true })
context.enrichedNotes = await foundry.applications.ux.TextEditor.implementation.enrichHTML(doc.system.notes, { async: true })
context.enrichedDescription = await foundry.applications.ux.TextEditor.implementation.enrichHTML(doc.system.description ?? "", { async: true })
context.enrichedNotes = await foundry.applications.ux.TextEditor.implementation.enrichHTML(doc.system.notes ?? "", { async: true })
break
}
return context
@@ -246,6 +259,10 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet {
this.document.createEmbeddedDocuments("Item", [{ name: game.i18n.localize("OATHHAMMER.NewItem.Equipment"), type: "equipment" }])
}
static #onCreateTrait(event, target) {
this.document.createEmbeddedDocuments("Item", [{ name: game.i18n.localize("OATHHAMMER.NewItem.Trait"), type: "trait" }])
}
static async #onRollSkill(event, target) {
const skillKey = target.dataset.skill
if (!skillKey) return
@@ -264,6 +281,16 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet {
await rollWeaponAttack(this.document, weapon, opts)
}
static async #onDefendWeapon(event, target) {
const weaponId = target.dataset.itemId
if (!weaponId) return
const weapon = this.document.items.get(weaponId)
if (!weapon) return
const opts = await OathHammerWeaponDialog.promptDefense(this.document, weapon)
if (!opts) return
await rollWeaponDefense(this.document, weapon, opts)
}
static async #onDamageWeapon(event, target) {
const weaponId = target.dataset.itemId
if (!weaponId) return
@@ -289,8 +316,26 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet {
if (!miracleId) return
const miracle = this.document.items.get(miracleId)
if (!miracle) return
if (this.document.system.miracleBlocked) {
ui.notifications.warn(game.i18n.localize("OATHHAMMER.Warning.MiracleBlocked"))
return
}
const opts = await OathHammerMiracleDialog.prompt(this.document, miracle)
if (!opts) return
await rollMiracleCast(this.document, miracle, opts)
}
static async #onResetMiracleBlocked() {
await this.document.update({ "system.miracleBlocked": false })
}
static async #onRollArmorSave(event, target) {
const armorId = target.dataset.itemId
if (!armorId) return
const armor = this.document.items.get(armorId)
if (!armor) return
const opts = await OathHammerArmorDialog.prompt(this.document, armor)
if (!opts) return
await rollArmorSave(this.document, armor, opts)
}
}

View File

@@ -28,15 +28,26 @@ export default class OathHammerClassSheet extends OathHammerItemSheet {
return context
}
/** @override — collect checkbox sets explicitly so unchecking all works */
_prepareSubmitData(event, form, formData) {
const data = super._prepareSubmitData(event, form, formData)
data["system.armorProficiency"] = Array.from(
form.querySelectorAll('input[name="system.armorProficiency"]:checked')
).map(el => el.value)
data["system.weaponProficiency"] = Array.from(
form.querySelectorAll('input[name="system.weaponProficiency"]:checked')
).map(el => el.value)
return data
/** @override */
_onRender(context, options) {
super._onRender(context, options)
// Handle proficiency checkboxes directly — FormDataExtended mishandles
// multiple same-named checkboxes, so we intercept the change event,
// collect all checked values ourselves, and stop propagation to prevent
// the generic submitOnChange handler from clobbering the data.
for (const cb of this.element.querySelectorAll('.proficiency-checkboxes input[type="checkbox"]')) {
cb.addEventListener("change", this.#onProficiencyChange.bind(this))
}
}
async #onProficiencyChange(event) {
event.stopPropagation()
const root = this.element
const armorProficiency = [...root.querySelectorAll('input[name="system.armorProficiency"]:checked')].map(e => e.value)
const weaponProficiency = [...root.querySelectorAll('input[name="system.weaponProficiency"]:checked')].map(e => e.value)
await this.document.update({
"system.armorProficiency": armorProficiency,
"system.weaponProficiency": weaponProficiency,
})
}
}

View File

@@ -129,6 +129,143 @@ export default class OathHammerWeaponDialog {
}
}
// ------------------------------------------------------------------ //
// DEFENSE DIALOG
// ------------------------------------------------------------------ //
/**
* Show the weapon defense dialog and return resolved options.
*
* Defense pool = Agility (or Might) + Defense skill + trait bonuses + armor penalty + diminish penalty + bonus
*
* Parry trait → red dice vs melee; +1 if two Parry weapons equipped
* Block trait → red dice vs ranged; +1 bonus always
* Diminishing defense: -2 per additional defense after the first in a turn
*/
static async promptDefense(actor, weapon) {
const sys = weapon.system
const actorSys = actor.system
const agiRank = actorSys.attributes.agility.rank
const mightRank = actorSys.attributes.might.rank
const defRank = actorSys.skills.defense.rank
// Detect this weapon's defense-relevant traits
const hasParry = sys.traits.has("parry")
const hasBlock = sys.traits.has("block")
// Count all equipped parry/block weapons (for +1 with two Parry weapons)
const equipped = actor.items.filter(i => i.type === "weapon" && i.system.equipped)
const parryCount = equipped.filter(w => w.system.traits?.has?.("parry") || [...(w.system.traits ?? [])].includes("parry")).length
const blockCount = equipped.filter(w => w.system.traits?.has?.("block") || [...(w.system.traits ?? [])].includes("block")).length
// Armor penalty from all equipped armors
const armorPenalty = actor.items
.filter(i => i.type === "armor" && i.system.equipped)
.reduce((sum, a) => sum + (a.system.penalty ?? 0), 0)
// Pre-select attack type: block weapons default to ranged, parry to melee
const defaultAttackType = hasBlock && !hasParry ? "ranged" : "melee"
const traitLabels = [...sys.traits].map(t => {
const key = SYSTEM.WEAPON_TRAITS[t]
return key ? game.i18n.localize(key) : t
})
const attackTypeOptions = [
{ value: "melee", label: game.i18n.localize("OATHHAMMER.Dialog.DefenseMelee"), selected: defaultAttackType === "melee" },
{ value: "ranged", label: game.i18n.localize("OATHHAMMER.Dialog.DefenseRanged"), selected: defaultAttackType === "ranged" },
]
const attrOptions = [
{ value: "agility", label: `${game.i18n.localize("OATHHAMMER.Attribute.Agility")} (${agiRank})`, selected: true },
{ value: "might", label: `${game.i18n.localize("OATHHAMMER.Attribute.Might")} (${mightRank})`, selected: false },
]
const diminishOptions = [
{ value: 0, label: game.i18n.localize("OATHHAMMER.Dialog.DiminishFirst"), selected: true },
{ value: -2, label: game.i18n.localize("OATHHAMMER.Dialog.DiminishSecond"), selected: false },
{ value: -4, label: game.i18n.localize("OATHHAMMER.Dialog.DiminishThird"), selected: false },
]
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,
weaponName: weapon.name,
weaponImg: weapon.img,
agiRank,
mightRank,
defRank,
hasParry,
hasBlock,
parryCount,
blockCount,
armorPenalty,
traits: traitLabels,
attackTypeOptions,
attrOptions,
diminishOptions,
bonusOptions,
rollModes,
visibility: game.settings.get("core", "rollMode"),
}
const content = await foundry.applications.handlebars.renderTemplate(
"systems/fvtt-oath-hammer/templates/weapon-defense-dialog.hbs",
context
)
const result = await foundry.applications.api.DialogV2.wait({
window: { title: game.i18n.format("OATHHAMMER.Dialog.WeaponDefenseTitle", { weapon: weapon.name }) },
classes: ["fvtt-oath-hammer"],
content,
rejectClose: false,
buttons: [{
label: game.i18n.localize("OATHHAMMER.Dialog.RollDefense"),
callback: (_ev, btn) => Object.fromEntries(
[...btn.form.elements].filter(e => e.name).map(e => [e.name, e.value])
),
}],
})
if (!result) return null
const attackType = result.attackType ?? defaultAttackType
const attrChoice = result.attribute ?? "agility"
const attrRank = attrChoice === "might" ? mightRank : agiRank
const diminishPenalty = parseInt(result.diminish) || 0
const bonus = parseInt(result.bonus) || 0
// Resolve red dice and trait bonus based on selected attack type
let redDice = false
let traitBonus = 0
if (attackType === "melee" && hasParry) {
redDice = true
traitBonus = parryCount >= 2 ? 1 : 0
} else if (attackType === "ranged" && hasBlock) {
redDice = true
traitBonus = 1
}
return {
attackType,
attrRank,
attrChoice,
redDice,
traitBonus,
armorPenalty,
diminishPenalty,
bonus,
visibility: result.visibility ?? game.settings.get("core", "rollMode"),
}
}
// ------------------------------------------------------------------ //
// DAMAGE DIALOG
// ------------------------------------------------------------------ //

View File

@@ -82,6 +82,8 @@ export default class OathHammerCharacter extends foundry.abstract.TypeDataModel
threshold: new fields.NumberField({ ...requiredInteger, initial: 10, min: 0 })
})
schema.miracleBlocked = new fields.BooleanField({ required: true, initial: false })
schema.movement = new fields.SchemaField({
base: new fields.NumberField({ ...requiredInteger, initial: 30, min: 0 }),
adjusted: new fields.NumberField({ ...requiredInteger, initial: 30, min: 0 })

View File

@@ -24,7 +24,7 @@ import { SYSTEM } from "./config/system.mjs"
* @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 { bonus = 0, luckSpend = 0, supporters = 0, attrOverride, visibility, flavor, explodeOn5 = false } = options
const sys = actor.system
const skillDef = SYSTEM.SKILLS[skillKey]
@@ -52,7 +52,8 @@ export async function rollSkillCheck(actor, skillKey, dv, options = {}) {
// Roll the dice pool
const roll = await new Roll(`${totalDice}d6`).evaluate()
// Count successes — exploding 6s produce additional dice
// Count successes — exploding dice produce additional dice
const explodeThreshold = explodeOn5 ? 5 : 6
let successes = 0
const diceResults = []
let extraDice = 0
@@ -60,7 +61,7 @@ export async function rollSkillCheck(actor, skillKey, dv, options = {}) {
for (const r of roll.dice[0].results) {
const val = r.result
if (val >= threshold) successes++
if (val === 6) extraDice++
if (val >= explodeThreshold) extraDice++
diceResults.push({ val, exploded: false })
}
@@ -70,12 +71,13 @@ export async function rollSkillCheck(actor, skillKey, dv, options = {}) {
for (const r of xRoll.dice[0].results) {
const val = r.result
if (val >= threshold) successes++
if (val === 6) extraDice++
if (val >= explodeThreshold) extraDice++
diceResults.push({ val, exploded: true })
}
}
const isSuccess = successes >= dv
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)}`)
@@ -89,18 +91,26 @@ export async function rollSkillCheck(actor, skillKey, dv, options = {}) {
}).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 * 2} ${game.i18n.localize("OATHHAMMER.Dialog.LuckSpend")} (${luckSpend}LP)`)
if (supporters > 0) modParts.push(`+${supporters} ${game.i18n.localize("OATHHAMMER.Dialog.Supporters")}`)
if (explodedCount > 0) modParts.push(`💥 ${explodedCount} ${game.i18n.localize("OATHHAMMER.Roll.Exploded")}`)
const modLine = modParts.length ? `<div class="oh-roll-mods">${modParts.join(" · ")}</div>` : ""
const resultClass = isSuccess ? "roll-success" : "roll-failure"
const resultLabel = isSuccess
? game.i18n.localize("OATHHAMMER.Roll.Success")
: game.i18n.localize("OATHHAMMER.Roll.Failure")
const 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 ?? `${skillLabel} ${game.i18n.localize("OATHHAMMER.Roll.Check")} (DV ${dv})`
const cardFlavor = flavor ?? (isOpposed
? `${skillLabel} ${game.i18n.localize("OATHHAMMER.Roll.Check")} (${game.i18n.localize("OATHHAMMER.Roll.Opposed")})`
: `${skillLabel} ${game.i18n.localize("OATHHAMMER.Roll.Check")} (DV ${dv})`)
const successDisplay = isOpposed ? String(successes) : `${successes} / ${dv}`
const content = `
<div class="oh-roll-card">
@@ -112,7 +122,7 @@ export async function rollSkillCheck(actor, skillKey, dv, options = {}) {
${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-successes">${successDisplay}</span>
<span class="oh-roll-verdict">${resultLabel}</span>
</div>
</div>
@@ -549,7 +559,247 @@ export async function rollMiracleCast(actor, miracle, options = {}) {
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, 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 modLine = modParts.length ? `<div class="oh-roll-mods">${modParts.join(" · ")}</div>` : ""
const content = `
<div class="oh-roll-card oh-defense-card">
<div class="oh-roll-header">
<i class="fa-solid fa-shield-halved oh-defense-icon"></i>
<span>${game.i18n.localize("OATHHAMMER.Roll.Defense")}${typeLabel}</span>
</div>
<div class="oh-roll-info">
<span>${attrLabel} ${attrRank} + ${skillLabel} ${defRank}</span>
<span>${colorEmoji} ${totalDice}d6 (${threshold}+)</span>
</div>
${modLine}
<div class="oh-roll-dice">${diceHtml}</div>
<div class="oh-roll-result">
<span class="oh-roll-successes">${successes} ${game.i18n.localize("OATHHAMMER.Roll.Successes")}</span>
<span class="oh-roll-verdict">${game.i18n.localize("OATHHAMMER.Roll.DefenseResult")}</span>
</div>
</div>
`
const rollMode = visibility ?? game.settings.get("core", "rollMode")
const msgData = {
speaker: ChatMessage.getSpeaker({ actor }),
content,
rolls: [roll],
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",
redDice = false,
traitBonus = 0,
armorPenalty = 0,
diminishPenalty = 0,
bonus = 0,
visibility,
} = options
const defRank = actor.system.skills.defense.rank
const totalDice = Math.max(attrRank + defRank + traitBonus + armorPenalty + diminishPenalty + bonus, 1)
const threshold = redDice ? 3 : 4
const colorEmoji = redDice ? "🔴" : "⬜"
const { roll, successes, diceResults } = await _rollPool(totalDice, threshold)
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")}`)
const modLine = modParts.length ? `<div class="oh-roll-mods">${modParts.join(" · ")}</div>` : ""
const content = `
<div class="oh-roll-card oh-defense-card">
<div class="oh-roll-header">
<img src="${weapon.img}" class="oh-card-weapon-img" alt="${weapon.name}" />
<span>${weapon.name}${game.i18n.localize("OATHHAMMER.Roll.Defense")} (${typeLabel})</span>
</div>
<div class="oh-roll-info">
<span>${attrLabel} ${attrRank} + ${skillLabel} ${defRank}</span>
<span>${colorEmoji} ${totalDice}d6 (${threshold}+)</span>
</div>
${modLine}
<div class="oh-roll-dice">${diceHtml}</div>
<div class="oh-roll-result">
<span class="oh-roll-successes">${successes} ${game.i18n.localize("OATHHAMMER.Roll.Successes")}</span>
<span class="oh-roll-verdict">${game.i18n.localize("OATHHAMMER.Roll.DefenseResult")}</span>
</div>
</div>
`
const rollMode = visibility ?? game.settings.get("core", "rollMode")
const msgData = {
speaker: ChatMessage.getSpeaker({ actor }),
content,
rolls: [roll],
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,
apPenalty = 0,
bonus = 0,
visibility,
} = options
// Armor CAN be reduced to 0 dice (fully bypassed by AP)
const totalDice = Math.max(av + apPenalty + bonus, 0)
const threshold = isReinforced ? 3 : 4
const colorEmoji = isReinforced ? "🔴" : "⬜"
let successes = 0
let diceHtml = `<em>${game.i18n.localize("OATHHAMMER.Roll.ArmorBypassed")}</em>`
let roll
if (totalDice > 0) {
const result = await _rollPool(totalDice, threshold)
roll = result.roll
successes = result.successes
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()
}
const modParts = []
if (apPenalty < 0) modParts.push(`AP ${apPenalty}`)
if (bonus !== 0) modParts.push(`${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}`)
const modLine = modParts.length ? `<div class="oh-roll-mods">${modParts.join(" · ")}</div>` : ""
const content = `
<div class="oh-roll-card oh-armor-card">
<div class="oh-roll-header">
<img src="${armor.img}" class="oh-card-weapon-img" alt="${armor.name}" />
<span>${armor.name} (AV ${av})</span>
</div>
<div class="oh-roll-info">
<span>${game.i18n.localize("OATHHAMMER.Roll.ArmorRoll")}</span>
<span>${colorEmoji} ${totalDice}d6 (${threshold}+)</span>
</div>
${modLine}
<div class="oh-roll-dice">${diceHtml}</div>
<div class="oh-roll-result ${successes > 0 ? "roll-success" : ""}">
<span class="oh-roll-successes">${successes} ${game.i18n.localize("OATHHAMMER.Roll.Successes")}</span>
<span class="oh-roll-verdict">${successes} ${game.i18n.localize("OATHHAMMER.Roll.Damage")}</span>
</div>
</div>
`
const rollMode = visibility ?? game.settings.get("core", "rollMode")
const msgData = {
speaker: ChatMessage.getSpeaker({ actor }),
content,
rolls: [roll],
sound: CONFIG.sounds.dice,
}
ChatMessage.applyRollMode(msgData, rollMode)
await ChatMessage.create(msgData)
return { successes, totalDice }
}