416 lines
16 KiB
JavaScript
416 lines
16 KiB
JavaScript
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" },
|
||
]
|
||
}
|