0381e8e024
Release Creation / build (release) Successful in 1m25s
- Add luck.bonus field to character DataModel; included in prepareDerivedData (luck.max = fate.rank + bonus); shown as editable +bonus field next to luck max in edit mode (same UX as grit bonus) - Add inventory.slotsBonus field to character DataModel; Equipment tab now shows an editable "Bonus Slots" input that adds to the calculated max slots (10 + Might×2 + bonus) - Replace single Enhancement <select> in spell cast dialog with a checkbox list; multiple enhancements can now be selected simultaneously — stress costs, pool penalties, and boolean flags (redDice, noStress) are aggregated across all active enhancements - Include skills.magic.modifier in basePool for both spell and miracle dialogs and in baseDice in rollSpellCast / rollMiracleCast; modifier is shown in the dialog pool-info line and in the chat card when non-zero - Fix: pool-reduction indicator in rollSpellCast now compares against intRank + magicRank + magicMod (was missing magicMod)
171 lines
6.8 KiB
JavaScript
171 lines
6.8 KiB
JavaScript
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 magicMod = actorSys.skills.magic.modifier ?? 0
|
|
const magicColor = actorSys.skills.magic.colorDiceType ?? "white"
|
|
const basePool = intRank + magicRank + magicMod
|
|
const magicModDisplay = magicMod > 0 ? `+${magicMod}` : magicMod < 0 ? `${magicMod}` : ""
|
|
|
|
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 colorOptions = [
|
|
{ value: "white", label: game.i18n.localize("OATHHAMMER.ColorDice.White"), selected: magicColor === "white" },
|
|
{ value: "red", label: game.i18n.localize("OATHHAMMER.ColorDice.Red"), selected: magicColor === "red" },
|
|
{ value: "black", label: game.i18n.localize("OATHHAMMER.ColorDice.Black"), selected: magicColor === "black" },
|
|
]
|
|
|
|
const enhancementOptions = Object.entries(SPELL_ENHANCEMENTS)
|
|
.filter(([key]) => key !== "none")
|
|
.map(([key, def]) => ({
|
|
value: key,
|
|
label: game.i18n.localize(def.label),
|
|
}))
|
|
|
|
const bonusOptions = Array.from({ length: 13 }, (_, i) => {
|
|
const v = i - 6
|
|
return { value: v, label: v > 0 ? `+${v}` : String(v), selected: v === 0 }
|
|
})
|
|
|
|
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,
|
|
}))
|
|
|
|
// Pool size selector: casters may roll fewer dice to reduce Arcane Stress (p.101)
|
|
const poolSizeOptions = Array.from({ length: basePool }, (_, i) => ({
|
|
value: i + 1,
|
|
label: `${i + 1}d`,
|
|
selected: i + 1 === basePool,
|
|
}))
|
|
|
|
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,
|
|
magicMod,
|
|
magicModDisplay,
|
|
basePool,
|
|
poolSizeOptions,
|
|
colorOptions,
|
|
currentStress,
|
|
stressThreshold,
|
|
isOverThreshold,
|
|
enhancementOptions,
|
|
bonusOptions,
|
|
availableLuck,
|
|
isHuman,
|
|
luckOptions,
|
|
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) => {
|
|
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
|
|
|
|
// Collect all checked enhancements and aggregate their effects
|
|
const selectedEnhs = Object.keys(SPELL_ENHANCEMENTS)
|
|
.filter(k => k !== "none" && result[`enh_${k}`] === "true")
|
|
const aggregated = selectedEnhs.reduce((acc, key) => {
|
|
const def = SPELL_ENHANCEMENTS[key]
|
|
acc.stress += def.stress
|
|
acc.penalty += def.penalty
|
|
if (def.redDice) acc.redDice = true
|
|
if (def.noStress) acc.noStress = true
|
|
return acc
|
|
}, { stress: 0, penalty: 0, redDice: false, noStress: false })
|
|
|
|
return {
|
|
dv,
|
|
enhancements: selectedEnhs.length ? selectedEnhs : ["none"],
|
|
stressCost: aggregated.stress,
|
|
poolPenalty: aggregated.penalty,
|
|
redDice: aggregated.redDice,
|
|
noStress: aggregated.noStress,
|
|
colorOverride: result.colorOverride ?? magicColor,
|
|
elementalBonus: parseInt(result.elementalBonus) || 0,
|
|
bonus: parseInt(result.bonus) || 0,
|
|
poolSize: Math.min(Math.max(1, parseInt(result.poolSize) || basePool), basePool),
|
|
grimPenalty: parseInt(result.noGrimoire) || 0,
|
|
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",
|
|
}
|
|
}
|
|
}
|