Add initiative rolls

This commit is contained in:
2026-03-18 16:51:10 +01:00
parent 000bf348a6
commit b2befe039e
30 changed files with 512 additions and 144 deletions

View File

@@ -2,7 +2,7 @@
* 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+)
* Reinforced trait on the armor → red dice (3+) by default
* Each success on the roll reduces incoming damage by 1.
*/
export default class OathHammerArmorDialog {
@@ -11,7 +11,8 @@ export default class OathHammerArmorDialog {
const sys = armor.system
const av = sys.armorValue ?? 0
const isReinforced = [...(sys.traits ?? [])].includes("reinforced")
const isReinforced = [...(sys.traits ?? [])].includes("reinforced")
const defaultColor = isReinforced ? "red" : "white"
// AP options — entered by the user based on the attacker's weapon
const apOptions = Array.from({ length: 9 }, (_, i) => ({
@@ -25,6 +26,12 @@ export default class OathHammerArmorDialog {
return { value: v, label: v > 0 ? `+${v}` : String(v), selected: v === 0 }
})
const colorOptions = [
{ value: "white", label: "⬜ White (4+)", selected: defaultColor === "white" },
{ value: "red", label: "🔴 Red (3+)", selected: defaultColor === "red" },
{ value: "black", label: "⬛ Black (2+)", selected: defaultColor === "black" },
]
const rollModes = foundry.utils.duplicate(CONFIG.Dice.rollModes)
const context = {
@@ -35,6 +42,7 @@ export default class OathHammerArmorDialog {
isReinforced,
apOptions,
bonusOptions,
colorOptions,
rollModes,
visibility: game.settings.get("core", "rollMode"),
}
@@ -51,9 +59,14 @@ export default class OathHammerArmorDialog {
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])
),
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
},
}],
})
@@ -62,9 +75,11 @@ export default class OathHammerArmorDialog {
return {
av,
isReinforced,
apPenalty: parseInt(result.ap) || 0,
bonus: parseInt(result.bonus) || 0,
visibility: result.visibility ?? game.settings.get("core", "rollMode"),
colorOverride: result.colorOverride || defaultColor,
apPenalty: parseInt(result.ap) || 0,
bonus: parseInt(result.bonus) || 0,
visibility: result.visibility ?? game.settings.get("core", "rollMode"),
explodeOn5: result.explodeOn5 === "true",
}
}
}

View File

@@ -63,9 +63,14 @@ export default class OathHammerMiracleDialog {
rejectClose: false,
buttons: [{
label: game.i18n.localize("OATHHAMMER.Dialog.InvokeMiracle"),
callback: (_ev, btn) => Object.fromEntries(
[...btn.form.elements].filter(e => e.name).map(e => [e.name, e.value])
),
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
},
}],
})
@@ -78,6 +83,7 @@ export default class OathHammerMiracleDialog {
isRitual,
bonus: parseInt(result.bonus) || 0,
visibility: result.visibility ?? game.settings.get("core", "rollMode"),
explodeOn5: result.explodeOn5 === "true",
}
}
}

View File

@@ -65,6 +65,8 @@ export default class OathHammerRollDialog {
}
const availableLuck = sys.luck?.value ?? 0
const isHuman = (sys.lineage?.name ?? "").toLowerCase() === "human"
const luckDicePerPoint = isHuman ? 3 : 2
const rollModes = foundry.utils.duplicate(CONFIG.Dice.rollModes)
// Build select option arrays
@@ -85,7 +87,7 @@ export default class OathHammerRollDialog {
const luckOptions = Array.from({ length: availableLuck + 1 }, (_, i) => ({
value: i,
label: i === 0 ? `0` : `${i} (+${i * 2}d)`,
label: i === 0 ? `0` : `${i} (+${i * luckDicePerPoint}d)`,
selected: i === 0,
}))
@@ -103,6 +105,7 @@ export default class OathHammerRollDialog {
colorLabel,
threshold,
availableLuck,
isHuman,
attrOptions,
isDualAttr: !!dualDef,
rollModes,
@@ -145,13 +148,14 @@ export default class OathHammerRollDialog {
const attrOverride = result.attrOverride || defaultAttrKey
return {
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",
dv: Math.max(0, parseInt(result.dv) ?? 2),
bonus: parseInt(result.bonus) || 0,
luckSpend: Math.min(Math.max(0, parseInt(result.luckSpend) || 0), availableLuck),
luckIsHuman: result.luckIsHuman === "true",
supporters: Math.max(0, parseInt(result.supporters) || 0),
explodeOn5: result.explodeOn5 === "true",
attrOverride,
visibility: result.visibility ?? game.settings.get("core", "rollMode"),
visibility: result.visibility ?? game.settings.get("core", "rollMode"),
}
}
}

View File

@@ -6,7 +6,7 @@ import OathHammerSpellDialog from "../spell-dialog.mjs"
import OathHammerMiracleDialog from "../miracle-dialog.mjs"
import OathHammerDefenseDialog from "../defense-dialog.mjs"
import OathHammerArmorDialog from "../armor-dialog.mjs"
import { rollSkillCheck, rollWeaponAttack, rollWeaponDamage, rollSpellCast, rollMiracleCast, rollArmorSave, rollWeaponDefense } from "../../rolls.mjs"
import { rollSkillCheck, rollWeaponAttack, rollWeaponDamage, rollSpellCast, rollMiracleCast, rollArmorSave, rollWeaponDefense, rollInitiativeCheck } from "../../rolls.mjs"
export default class OathHammerCharacterSheet extends OathHammerActorSheet {
/** @override */
@@ -25,6 +25,7 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet {
createMiracle: OathHammerCharacterSheet.#onCreateMiracle,
createEquipment: OathHammerCharacterSheet.#onCreateEquipment,
createTrait: OathHammerCharacterSheet.#onCreateTrait,
createOath: OathHammerCharacterSheet.#onCreateOath,
rollSkill: OathHammerCharacterSheet.#onRollSkill,
attackWeapon: OathHammerCharacterSheet.#onAttackWeapon,
defendWeapon: OathHammerCharacterSheet.#onDefendWeapon,
@@ -33,6 +34,7 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet {
castMiracle: OathHammerCharacterSheet.#onCastMiracle,
rollArmorSave: OathHammerCharacterSheet.#onRollArmorSave,
resetMiracleBlocked: OathHammerCharacterSheet.#onResetMiracleBlocked,
rollInitiative: OathHammerCharacterSheet.#onRollInitiative,
},
}
@@ -96,15 +98,18 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet {
_typeLabel: typeKey ? game.i18n.localize(typeKey) : a.system.traitType,
_usageLabel: isPassive
? game.i18n.localize("OATHHAMMER.UsagePeriod.None")
: `${a.system.maxUses > 0 ? a.system.maxUses + " / " : ""}${game.i18n.localize(periodKey)}`
: `${a.system.maxUses > 0 ? a.system.maxUses + " / " : ""}${game.i18n.localize(periodKey)}`,
_descTooltip: _stripHtml(a.system.description)
}
})
context.oaths = doc.itemTypes.oath.map(o => {
const typeEntry = SYSTEM.OATH_TYPES[o.system.oathType]
const parts = [o.system.tenet, o.system.boon, o.system.bane].filter(Boolean)
return {
id: o.id, uuid: o.uuid, img: o.img, name: o.name, system: o.system,
_typeLabel: typeEntry ? game.i18n.localize(typeEntry.label) : o.system.oathType,
_violated: o.system.violated
_violated: o.system.violated,
_descTooltip: _stripHtml(parts.join(" "))
}
})
context.enrichedBackground = await foundry.applications.ux.TextEditor.implementation.enrichHTML(doc.system.background ?? "", { async: true })
@@ -160,6 +165,7 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet {
id: w.id, uuid: w.uuid, img: w.img, name: w.name, system: w.system,
_groupLabel: groupKey ? game.i18n.localize(groupKey) : w.system.proficiencyGroup,
_traitsTooltip: traitsLabel || null,
_descTooltip: _stripHtml(w.system.description),
_isMagic: w.system.isMagic
}
})
@@ -173,6 +179,7 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet {
id: a.id, uuid: a.uuid, img: a.img, name: a.name, system: a.system,
_typeLabel: typeKey ? game.i18n.localize(typeKey) : a.system.armorType,
_traitsTooltip: traitsLabel || null,
_descTooltip: _stripHtml(a.system.description),
_isMagic: a.system.isMagic
}
})
@@ -181,16 +188,31 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet {
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
// Show current initiative score if actor is in an active combat
const combatant = game.combat?.combatants.find(c => c.actor?.id === doc.id)
context.combatantInitiative = combatant?.initiative ?? null
break
case "magic":
context.tab = context.tabs.magic
context.spells = doc.itemTypes.spell
context.miracles = doc.itemTypes.miracle
context.spells = doc.itemTypes.spell.map(s => ({
id: s.id, uuid: s.uuid, img: s.img, name: s.name, system: s.system,
_descTooltip: _stripHtml(s.system.effect)
}))
context.miracles = doc.itemTypes.miracle.map(m => ({
id: m.id, uuid: m.uuid, img: m.img, name: m.name, system: m.system,
_descTooltip: _stripHtml(m.system.effect)
}))
break
case "equipment":
context.tab = context.tabs.equipment
context.equipment = doc.itemTypes.equipment
context.magicItems = doc.itemTypes["magic-item"]
context.equipment = doc.itemTypes.equipment.map(e => ({
id: e.id, uuid: e.uuid, img: e.img, name: e.name, system: e.system,
_descTooltip: _stripHtml(e.system.description)
}))
context.magicItems = doc.itemTypes["magic-item"].map(m => ({
id: m.id, uuid: m.uuid, img: m.img, name: m.name, system: m.system,
_descTooltip: _stripHtml(m.system.description)
}))
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
@@ -263,6 +285,10 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet {
this.document.createEmbeddedDocuments("Item", [{ name: game.i18n.localize("OATHHAMMER.NewItem.Trait"), type: "trait" }])
}
static #onCreateOath(event, target) {
this.document.createEmbeddedDocuments("Item", [{ name: game.i18n.localize("OATHHAMMER.NewItem.Oath"), type: "oath" }])
}
static async #onRollSkill(event, target) {
const skillKey = target.dataset.skill
if (!skillKey) return
@@ -338,4 +364,23 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet {
if (!opts) return
await rollArmorSave(this.document, armor, opts)
}
static async #onRollInitiative() {
const actor = this.document
const combatant = game.combat?.combatants.find(c => c.actor?.id === actor.id)
if (combatant) {
// Delegate to OathHammerCombat.rollInitiative — posts to chat and updates tracker
await game.combat.rollInitiative([combatant.id])
} else {
// Not in combat — roll for display only
await rollInitiativeCheck(actor)
}
}
}
/** Strip HTML tags and collapse whitespace for use in data-tooltip attributes. */
function _stripHtml(html, maxLen = 300) {
if (!html) return ""
const text = html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim()
return text.length > maxLen ? text.slice(0, maxLen - 1) + "…" : text
}

View File

@@ -1,4 +1,5 @@
import OathHammerActorSheet from "./base-actor-sheet.mjs"
import { rollInitiativeCheck } from "../../rolls.mjs"
export default class OathHammerNPCSheet extends OathHammerActorSheet {
/** @override */
@@ -11,6 +12,9 @@ export default class OathHammerNPCSheet extends OathHammerActorSheet {
window: {
contentClasses: ["npc-content"],
},
actions: {
rollInitiative: OathHammerNPCSheet.#onRollInitiative,
},
}
/** @override */
@@ -62,6 +66,7 @@ export default class OathHammerNPCSheet extends OathHammerActorSheet {
case "combat":
context.tab = context.tabs.combat
context.weapons = doc.itemTypes.weapon
context.combatantInitiative = game.combat?.combatants.find(c => c.actor?.id === doc.id)?.initiative ?? null
break
case "notes":
context.tab = context.tabs.notes
@@ -80,4 +85,14 @@ export default class OathHammerNPCSheet extends OathHammerActorSheet {
return this._onDropItem(item)
}
}
static async #onRollInitiative() {
const actor = this.document
const combatant = game.combat?.combatants.find(c => c.actor?.id === actor.id)
if (combatant) {
await game.combat.rollInitiative([combatant.id])
} else {
await rollInitiativeCheck(actor)
}
}
}

View File

@@ -91,9 +91,14 @@ export default class OathHammerSpellDialog {
rejectClose: false,
buttons: [{
label: game.i18n.localize("OATHHAMMER.Dialog.CastSpell"),
callback: (_ev, btn) => Object.fromEntries(
[...btn.form.elements].filter(e => e.name).map(e => [e.name, e.value])
),
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
},
}],
})
@@ -113,6 +118,7 @@ export default class OathHammerSpellDialog {
bonus: parseInt(result.bonus) || 0,
grimPenalty: parseInt(result.noGrimoire) || 0,
visibility: result.visibility ?? game.settings.get("core", "rollMode"),
explodeOn5: result.explodeOn5 === "true",
}
}
}

View File

@@ -97,6 +97,7 @@ export default class OathHammerWeaponDialog {
traits: traitLabels,
attackBonusOptions,
rangeOptions,
colorOptions: _colorOptions(skillColor),
rollModes,
visibility: game.settings.get("core", "rollMode"),
}
@@ -113,9 +114,14 @@ export default class OathHammerWeaponDialog {
rejectClose: false,
buttons: [{
label: game.i18n.localize("OATHHAMMER.Dialog.RollAttack"),
callback: (_ev, btn) => Object.fromEntries(
[...btn.form.elements].filter(e => e.name).map(e => [e.name, e.value])
),
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
},
}],
})
@@ -124,8 +130,10 @@ export default class OathHammerWeaponDialog {
attackBonus: parseInt(result.attackBonus) || 0,
rangeCondition: parseInt(result.rangeCondition) || 0,
attrOverride: result.attrOverride || defaultAttr,
colorOverride: result.colorOverride || skillColor,
visibility: result.visibility ?? game.settings.get("core", "rollMode"),
autoAttackBonus,
explodeOn5: result.explodeOn5 === "true",
}
}
@@ -188,6 +196,12 @@ export default class OathHammerWeaponDialog {
{ 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 }
@@ -212,6 +226,7 @@ export default class OathHammerWeaponDialog {
attrOptions,
diminishOptions,
bonusOptions,
colorOptions: _colorOptions(defaultDefenseColor),
rollModes,
visibility: game.settings.get("core", "rollMode"),
}
@@ -228,9 +243,14 @@ export default class OathHammerWeaponDialog {
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])
),
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
},
}],
})
@@ -241,28 +261,24 @@ export default class OathHammerWeaponDialog {
const attrRank = attrChoice === "might" ? mightRank : agiRank
const diminishPenalty = parseInt(result.diminish) || 0
const bonus = parseInt(result.bonus) || 0
const colorOverride = result.colorOverride || defaultDefenseColor
// Resolve red dice and trait bonus based on selected attack type
let redDice = false
// Trait bonus based on selected attack type (color is now user-controlled)
let traitBonus = 0
if (attackType === "melee" && hasParry) {
redDice = true
traitBonus = parryCount >= 2 ? 1 : 0
} else if (attackType === "ranged" && hasBlock) {
redDice = true
traitBonus = 1
}
if (attackType === "melee" && hasParry) traitBonus = parryCount >= 2 ? 1 : 0
if (attackType === "ranged" && hasBlock) traitBonus = 1
return {
attackType,
attrRank,
attrChoice,
redDice,
colorOverride,
traitBonus,
armorPenalty,
diminishPenalty,
bonus,
visibility: result.visibility ?? game.settings.get("core", "rollMode"),
visibility: result.visibility ?? game.settings.get("core", "rollMode"),
explodeOn5: result.explodeOn5 === "true",
}
}
@@ -350,3 +366,11 @@ export default class OathHammerWeaponDialog {
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" },
]
}