Add luck option after roll, attributes above 6, fix miracle icon and grit bonus
All checks were successful
Release Creation / build (release) Successful in 1m36s

This commit is contained in:
2026-04-20 08:23:33 +02:00
parent 36cb3bc755
commit b4211c121d
19 changed files with 373 additions and 26 deletions

View File

@@ -0,0 +1,52 @@
/**
* Dialog for spending Luck Points after a roll result is known.
* Called from the "🍀 Luck" button on chat cards.
*/
export default class OathHammerLuckRollDialog {
/**
* Prompt the actor's owner to spend luck points after seeing a roll result.
* @param {Actor} actor
* @returns {Promise<{luckSpend: number, luckIsHuman: boolean}|null>}
*/
static async prompt(actor) {
const actorSys = actor.system
const availableLuck = actorSys.luck?.value ?? 0
if (availableLuck <= 0) {
ui.notifications.warn(game.i18n.localize("OATHHAMMER.Roll.NoLuckLeft"))
return null
}
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} LP (+${i * luckDicePerPoint}d)`,
selected: i === 1,
}))
const content = await foundry.applications.handlebars.renderTemplate(
"systems/fvtt-oath-hammer/templates/luck-roll-dialog.hbs",
{ availableLuck, isHuman, luckOptions }
)
const result = await foundry.applications.api.DialogV2.wait({
window: { title: game.i18n.format("OATHHAMMER.Dialog.LuckPostRollTitle", { name: actor.name }) },
classes: ["fvtt-oath-hammer"],
content,
rejectClose: false,
buttons: [{
label: game.i18n.localize("OATHHAMMER.Dialog.LuckPostRollConfirm"),
callback: (_ev, btn) => {
const spend = parseInt(btn.form.elements.luckSpend?.value) || 0
const isH = btn.form.elements.luckIsHuman?.checked ?? false
return { luckSpend: spend, luckIsHuman: isH }
},
}],
})
if (!result || result.luckSpend <= 0) return null
return result
}
}

View File

@@ -79,7 +79,7 @@ export default class OathHammerItemSheet extends HandlebarsApplicationMixin(foun
)
// Weapon-specific numeric selects
context.damageModChoices = Object.fromEntries(
Array.from({ length: 10 }, (_, i) => [i - 4, i - 4 >= 0 ? `+${i - 4}` : String(i - 4)])
Array.from({ length: 20 }, (_, i) => [i - 4, i - 4 >= 0 ? `+${i - 4}` : String(i - 4)])
)
context.apChoices = Object.fromEntries(
Array.from({ length: 7 }, (_, i) => [i, String(i)])

View File

@@ -348,7 +348,7 @@ export default class OathHammerWeaponDialog {
selected: i === defaultSV,
}))
const damageBonusOptions = Array.from({ length: 9 }, (_, i) => {
const damageBonusOptions = Array.from({ length: 20 }, (_, i) => {
const v = i - 4
return { value: v, label: v > 0 ? `+${v}` : String(v), selected: v === 0 }
})

View File

@@ -16,7 +16,7 @@ export default class OathHammerCharacter extends foundry.abstract.TypeDataModel
})
const attributeField = () => new fields.SchemaField({
rank: new fields.NumberField({ ...requiredInteger, initial: 1, min: 1, max: 4 })
rank: new fields.NumberField({ ...requiredInteger, initial: 1, min: 1 })
})
schema.attributes = new fields.SchemaField({
might: attributeField(),
@@ -68,7 +68,8 @@ export default class OathHammerCharacter extends foundry.abstract.TypeDataModel
schema.grit = new fields.SchemaField({
value: new fields.NumberField({ ...requiredInteger, initial: 2, min: 0 }),
max: new fields.NumberField({ ...requiredInteger, initial: 2, min: 0 })
max: new fields.NumberField({ ...requiredInteger, initial: 2, min: 0 }),
bonus: new fields.NumberField({ required: true, nullable: false, integer: true, initial: 0 }),
})
// Luck.max is derived from fate.rank; resets at session start.
@@ -125,8 +126,8 @@ export default class OathHammerCharacter extends foundry.abstract.TypeDataModel
prepareDerivedData() {
super.prepareDerivedData()
// Grit max = Resilience skill rank + Toughness attribute rank (rulebook p.5)
this.grit.max = this.skills.resilience.rank + this.attributes.toughness.rank
// Grit max = Resilience skill rank + Toughness attribute rank + bonus (rulebook p.5)
this.grit.max = this.skills.resilience.rank + this.attributes.toughness.rank + (this.grit.bonus ?? 0)
// Luck max = Fate rank; restores at session start
this.luck.max = this.attributes.fate.rank
// Defense score = 10 + Agility + Armor Rating + bonus

View File

@@ -15,7 +15,7 @@ export default class OathHammerWeapon extends foundry.abstract.TypeDataModel {
// Damage: melee/throwing = Might rank + damageMod dice; bows = baseDice (fixed, no Might)
// usesMight is now derived from proficiencyGroup (see getter below)
schema.damageMod = new fields.NumberField({ ...requiredInteger, initial: 0, min: -4, max: 16 })
schema.damageMod = new fields.NumberField({ ...requiredInteger, initial: 0, min: -4, max: 15 })
// AP (Armor Penetration): penalty imposed on armor/defense rolls
schema.ap = new fields.NumberField({ ...requiredInteger, initial: 0, min: 0, max: 16 })

View File

@@ -139,6 +139,7 @@ export async function rollSkillCheck(actor, skillKey, dv, options = {}) {
content,
rolls: allRolls,
sound: CONFIG.sounds.dice,
flags: { "fvtt-oath-hammer": { luckRoll: _luckFlagData(actor, threshold, colorType, dv, isOpposed, explodeOn5) } },
}
ChatMessage.applyRollMode(msgData, rollMode)
await ChatMessage.create(msgData)
@@ -224,6 +225,59 @@ export function _diceHtml(diceResults, threshold) {
}).join(" ")
}
/**
* Build the luck flag data to store on a chat message, enabling post-roll luck spending.
* @param {Actor} actor
* @param {number} threshold Dice success threshold (2/3/4)
* @param {string} colorType "white"|"red"|"black"
* @param {number} dv Difficulty value (0 = opposed)
* @param {boolean} isOpposed
* @param {boolean} explodeOn5
*/
export function _luckFlagData(actor, threshold, colorType, dv, isOpposed, explodeOn5) {
return { actorUuid: actor.uuid, threshold, colorType, dv, isOpposed, explodeOn5 }
}
/**
* Perform a post-roll luck spend: roll extra dice and update the chat message.
* @param {ChatMessage} message
* @param {number} luckSpend
* @param {boolean} luckIsHuman
*/
export async function rollPostRollLuck(message, luckSpend, luckIsHuman) {
const flag = message.getFlag("fvtt-oath-hammer", "luckRoll")
if (!flag || flag.used || luckSpend <= 0) return
const actor = await fromUuid(flag.actorUuid)
if (!actor) return
const currentLuck = actor.system.luck?.value ?? 0
const safeSpend = Math.min(luckSpend, currentLuck)
if (safeSpend <= 0) {
ui.notifications.warn(game.i18n.localize("OATHHAMMER.Roll.NoLuckLeft"))
return
}
const luckDicePerPoint = luckIsHuman ? 3 : 2
const extraDice = safeSpend * luckDicePerPoint
const { successes, diceResults } = await _rollPool(extraDice, flag.threshold, flag.explodeOn5)
await actor.update({ "system.luck.value": Math.max(0, currentLuck - safeSpend) })
const luckDiceHtml = _diceHtml(diceResults, flag.threshold)
await message.setFlag("fvtt-oath-hammer", "luckRoll", {
...flag,
used: true,
luckSpend: safeSpend,
luckIsHuman,
bonusSuccesses: successes,
extraDiceResults: diceResults,
luckDiceHtml,
})
}
// ============================================================
// WEAPON ATTACK ROLL
// ============================================================
@@ -309,7 +363,7 @@ export async function rollWeaponAttack(actor, weapon, options = {}) {
content,
rolls: rolls,
sound: CONFIG.sounds.dice,
flags: { "fvtt-oath-hammer": { weaponAttack: flagData } },
flags: { "fvtt-oath-hammer": { weaponAttack: flagData, luckRoll: _luckFlagData(actor, threshold, colorType, 0, true, explodeOn5) } },
}
ChatMessage.applyRollMode(msgData, rollMode)
await ChatMessage.create(msgData)
@@ -506,11 +560,13 @@ export async function rollSpellCast(actor, spell, options = {}) {
`
const rollMode = visibility ?? game.settings.get("core", "rollMode")
const spellColorType = redDice ? "red" : "white"
const msgData = {
speaker: ChatMessage.getSpeaker({ actor }),
content,
rolls: rolls,
sound: CONFIG.sounds.dice,
flags: { "fvtt-oath-hammer": { luckRoll: _luckFlagData(actor, threshold, spellColorType, dv, false, explodeOn5) } },
}
ChatMessage.applyRollMode(msgData, rollMode)
await ChatMessage.create(msgData)
@@ -608,6 +664,7 @@ export async function rollMiracleCast(actor, miracle, options = {}) {
content,
rolls: rolls,
sound: CONFIG.sounds.dice,
flags: { "fvtt-oath-hammer": { luckRoll: _luckFlagData(actor, threshold, "white", dv, false, explodeOn5) } },
}
ChatMessage.applyRollMode(msgData, rollMode)
await ChatMessage.create(msgData)
@@ -683,11 +740,13 @@ export async function rollDefense(actor, options = {}) {
`
const rollMode = visibility ?? game.settings.get("core", "rollMode")
const defColorType = redDice ? "red" : "white"
const msgData = {
speaker: ChatMessage.getSpeaker({ actor }),
content,
rolls: rolls,
sound: CONFIG.sounds.dice,
flags: { "fvtt-oath-hammer": { luckRoll: _luckFlagData(actor, threshold, defColorType, 0, true, false) } },
}
ChatMessage.applyRollMode(msgData, rollMode)
await ChatMessage.create(msgData)
@@ -786,6 +845,7 @@ export async function rollWeaponDefense(actor, weapon, options = {}) {
content,
rolls: rolls,
sound: CONFIG.sounds.dice,
flags: { "fvtt-oath-hammer": { luckRoll: _luckFlagData(actor, threshold, colorOverride, 0, true, explodeOn5) } },
}
ChatMessage.applyRollMode(msgData, rollMode)
await ChatMessage.create(msgData)