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.proficiencyGroup === "bows" || sys.proficiencyGroup === "throwing" const skillKey = (sys.skillOverride && SYSTEM.SKILLS[sys.skillOverride]) ? sys.skillOverride : (isRanged ? "shooting" : "fighting") const skillDef = SYSTEM.SKILLS[skillKey] const defaultAttr = skillDef.attribute const 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") // Luck const availableLuck = actorSys.luck?.value ?? 0 const isHuman = (actorSys.lineage?.name ?? "").toLowerCase() === "human" const luckDicePerPoint = isHuman ? 3 : 2 const luckOptions = Array.from({ length: availableLuck + 1 }, (_, i) => ({ value: i, label: i === 0 ? "0" : `${i} (+${i * luckDicePerPoint}d)`, selected: i === 0, })) // 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 damageAttrRank = actorSys.attributes[skillDef.attribute].rank const baseDamageDice = sys.usesMight ? Math.max(damageAttrRank + 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 rangeConditions = [ { name: "range_long", penalty: -1, label: `${game.i18n.localize("OATHHAMMER.Dialog.RangeLong")} (−1)` }, { name: "range_moving", penalty: -2, label: `${game.i18n.localize("OATHHAMMER.Dialog.RangeMoving")} (−2)` }, { name: "range_concealment", penalty: -2, label: `${game.i18n.localize("OATHHAMMER.Dialog.RangeConcealment")} (−2)` }, { name: "range_cover", penalty: -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, rangeConditions, colorOptions: _colorOptions(skillColor), rollModes, visibility: game.settings.get("core", "rollMode"), availableLuck, isHuman, luckOptions, } 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 }), resizable: true }, classes: ["fvtt-oath-hammer"], position: { width: 420 }, content, rejectClose: false, buttons: [{ label: game.i18n.localize("OATHHAMMER.Dialog.RollAttack"), callback: (_ev, btn) => { const out = {} for (const el of btn.form.elements) { if (!el.name) continue out[el.name] = el.type === "checkbox" ? String(el.checked) : el.value } return out }, }], }) if (!result) return null return { attackBonus: parseInt(result.attackBonus) || 0, rangeCondition: (result.range_long === "true" ? -1 : 0) + (result.range_moving === "true" ? -2 : 0) + (result.range_concealment === "true" ? -2 : 0) + (result.range_cover === "true" ? -3 : 0), attrOverride: result.attrOverride || defaultAttr, colorOverride: result.colorOverride || skillColor, visibility: result.visibility ?? game.settings.get("core", "rollMode"), autoAttackBonus, explodeOn5: result.explodeOn5 === "true", luckSpend: Math.min(Math.max(0, parseInt(result.luckSpend) || 0), availableLuck), luckIsHuman: result.luckIsHuman === "true", } } // ------------------------------------------------------------------ // // 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) // Luck const availableLuck = actorSys.luck?.value ?? 0 const isHuman = (actorSys.lineage?.name ?? "").toLowerCase() === "human" const luckDicePerPoint = isHuman ? 3 : 2 const luckOptions = Array.from({ length: availableLuck + 1 }, (_, i) => ({ value: i, label: i === 0 ? "0" : `${i} (+${i * luckDicePerPoint}d)`, selected: i === 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 }, ] // Default dice color: red if parry/block trait matches the default attack type, else white const defaultDefenseColor = ( (defaultAttackType === "melee" && hasParry) || (defaultAttackType === "ranged" && hasBlock) ) ? "red" : "white" 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, colorOptions: _colorOptions(defaultDefenseColor), rollModes, visibility: game.settings.get("core", "rollMode"), availableLuck, isHuman, luckOptions, } 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 }), resizable: true }, classes: ["fvtt-oath-hammer"], position: { width: 420 }, content, rejectClose: false, buttons: [{ label: game.i18n.localize("OATHHAMMER.Dialog.RollDefense"), callback: (_ev, btn) => { const out = {} for (const el of btn.form.elements) { if (!el.name) continue out[el.name] = el.type === "checkbox" ? String(el.checked) : el.value } return out }, }], }) 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 const colorOverride = result.colorOverride || defaultDefenseColor // Trait bonus based on selected attack type (color is now user-controlled) let traitBonus = 0 if (attackType === "melee" && hasParry) traitBonus = parryCount >= 2 ? 1 : 0 if (attackType === "ranged" && hasBlock) traitBonus = 1 return { attackType, attrRank, attrChoice, colorOverride, traitBonus, armorPenalty, diminishPenalty, bonus, visibility: result.visibility ?? game.settings.get("core", "rollMode"), explodeOn5: result.explodeOn5 === "true", luckSpend: Math.min(Math.max(0, parseInt(result.luckSpend) || 0), availableLuck), luckIsHuman: result.luckIsHuman === "true", } } // ------------------------------------------------------------------ // // 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 isRanged = sys.proficiencyGroup === "bows" || sys.proficiencyGroup === "throwing" const damageSkillKey = (sys.skillOverride && SYSTEM.SKILLS[sys.skillOverride]) ? sys.skillOverride : (isRanged ? "shooting" : "fighting") const damageAttrKey = SYSTEM.SKILLS[damageSkillKey].attribute const damageAttrRank = actorSys.attributes[damageAttrKey].rank const baseDamageDice = sys.usesMight ? Math.max(damageAttrRank + 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 }), resizable: true }, classes: ["fvtt-oath-hammer"], position: { width: 420 }, 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) } function _colorOptions(defaultColor = "white") { return [ { value: "white", label: "⬜ White (4+)", selected: defaultColor === "white" }, { value: "red", label: "🔴 Red (3+)", selected: defaultColor === "red" }, { value: "black", label: "⬛ Black (2+)", selected: defaultColor === "black" }, ] }