Enhancements as per issue tracking sheet
This commit is contained in:
@@ -14,6 +14,17 @@ export default class OathHammerArmorDialog {
|
||||
const isReinforced = [...(sys.traits ?? [])].includes("reinforced")
|
||||
const defaultColor = isReinforced ? "red" : "white"
|
||||
|
||||
// Luck
|
||||
const actorSys = actor.system
|
||||
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,
|
||||
}))
|
||||
|
||||
// AP options — entered by the user based on the attacker's weapon
|
||||
const apOptions = Array.from({ length: 9 }, (_, i) => ({
|
||||
value: -i,
|
||||
@@ -45,6 +56,9 @@ export default class OathHammerArmorDialog {
|
||||
colorOptions,
|
||||
rollModes,
|
||||
visibility: game.settings.get("core", "rollMode"),
|
||||
availableLuck,
|
||||
isHuman,
|
||||
luckOptions,
|
||||
}
|
||||
|
||||
const content = await foundry.applications.handlebars.renderTemplate(
|
||||
@@ -55,6 +69,7 @@ export default class OathHammerArmorDialog {
|
||||
const result = await foundry.applications.api.DialogV2.wait({
|
||||
window: { title: game.i18n.format("OATHHAMMER.Dialog.ArmorRollTitle", { armor: armor.name }) },
|
||||
classes: ["fvtt-oath-hammer"],
|
||||
position: { width: 420 },
|
||||
content,
|
||||
rejectClose: false,
|
||||
buttons: [{
|
||||
@@ -80,6 +95,8 @@ export default class OathHammerArmorDialog {
|
||||
bonus: parseInt(result.bonus) || 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ export default class OathHammerDefenseDialog {
|
||||
const result = await foundry.applications.api.DialogV2.wait({
|
||||
window: { title: game.i18n.format("OATHHAMMER.Dialog.DefenseTitle", { actor: actor.name }) },
|
||||
classes: ["fvtt-oath-hammer"],
|
||||
position: { width: 420 },
|
||||
content,
|
||||
rejectClose: false,
|
||||
buttons: [{
|
||||
|
||||
123
module/applications/free-roll.mjs
Normal file
123
module/applications/free-roll.mjs
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Free Dice Roll widget injected into the Foundry chat sidebar.
|
||||
*
|
||||
* Provides a compact bar for GM and players to roll any dice pool without
|
||||
* needing an actor — useful for quick checks, table rolls, narration, etc.
|
||||
*
|
||||
* Features:
|
||||
* - Pool size (1–20 dice)
|
||||
* - Color: White (4+), Red (3+), Black (2+)
|
||||
* - Explode on 5+ checkbox
|
||||
*/
|
||||
|
||||
import { _rollPool, _diceHtml } from "../rolls.mjs"
|
||||
|
||||
/**
|
||||
* Inject the Free Roll bar into the ChatLog HTML.
|
||||
* Called from `Hooks.on("renderChatLog", ...)`.
|
||||
*
|
||||
* @param {Application} _chatLog
|
||||
* @param {HTMLElement} html
|
||||
*/
|
||||
export function injectFreeRollBar(_chatLog, html) {
|
||||
// Avoid double-injection on re-renders
|
||||
if (html.querySelector(".oh-free-roll-bar")) return
|
||||
|
||||
const bar = document.createElement("div")
|
||||
bar.className = "oh-free-roll-bar"
|
||||
bar.innerHTML = `
|
||||
<span class="oh-frb-label">
|
||||
<i class="fa-solid fa-dice-d6"></i>
|
||||
${game.i18n.localize("OATHHAMMER.FreeRoll.Label")}
|
||||
</span>
|
||||
<div class="oh-frb-controls">
|
||||
<select class="oh-frb-pool" title="${game.i18n.localize("OATHHAMMER.FreeRoll.PoolTitle")}">
|
||||
${Array.from({length: 16}, (_, i) => `<option value="${i+1}"${i+1===2?" selected":""}>${i+1}d</option>`).join("")}
|
||||
</select>
|
||||
<select class="oh-frb-color" title="${game.i18n.localize("OATHHAMMER.FreeRoll.ColorTitle")}">
|
||||
<option value="white">⬜ ${game.i18n.localize("OATHHAMMER.FreeRoll.ColorWhite")}</option>
|
||||
<option value="red">🔴 ${game.i18n.localize("OATHHAMMER.FreeRoll.ColorRed")}</option>
|
||||
<option value="black">⬛ ${game.i18n.localize("OATHHAMMER.FreeRoll.ColorBlack")}</option>
|
||||
</select>
|
||||
<label class="oh-frb-explode-label" title="${game.i18n.localize("OATHHAMMER.FreeRoll.ExplodeTitle")}">
|
||||
<input type="checkbox" class="oh-frb-explode">
|
||||
💥 5+
|
||||
</label>
|
||||
<button type="button" class="oh-frb-roll-btn">
|
||||
${game.i18n.localize("OATHHAMMER.FreeRoll.Roll")}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
|
||||
bar.querySelector(".oh-frb-roll-btn").addEventListener("click", () => {
|
||||
const pool = parseInt(bar.querySelector(".oh-frb-pool").value) || 2
|
||||
const color = bar.querySelector(".oh-frb-color").value
|
||||
const explode5 = bar.querySelector(".oh-frb-explode").checked
|
||||
rollFree(pool, color, explode5)
|
||||
})
|
||||
|
||||
// Insert between .chat-scroll and .chat-form
|
||||
const chatForm = html.querySelector(".chat-form")
|
||||
if (chatForm) {
|
||||
html.insertBefore(bar, chatForm)
|
||||
} else {
|
||||
html.appendChild(bar)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a free dice roll and post the result to chat.
|
||||
*
|
||||
* @param {number} pool Number of d6 to roll
|
||||
* @param {string} colorType "white" | "red" | "black"
|
||||
* @param {boolean} explode5 True to explode on 5+
|
||||
*/
|
||||
export async function rollFree(pool, colorType, explode5 = false) {
|
||||
const threshold = colorType === "black" ? 2 : colorType === "red" ? 3 : 4
|
||||
const colorEmoji = colorType === "black" ? "⬛" : colorType === "red" ? "🔴" : "⬜"
|
||||
const colorLabel = colorType === "black"
|
||||
? game.i18n.localize("OATHHAMMER.FreeRoll.ColorBlack")
|
||||
: colorType === "red"
|
||||
? game.i18n.localize("OATHHAMMER.FreeRoll.ColorRed")
|
||||
: game.i18n.localize("OATHHAMMER.FreeRoll.ColorWhite")
|
||||
|
||||
const { rolls, successes, diceResults } = await _rollPool(pool, threshold, explode5)
|
||||
|
||||
const explodedCount = diceResults.filter(d => d.exploded).length
|
||||
const diceHtml = _diceHtml(diceResults, threshold)
|
||||
|
||||
const modParts = []
|
||||
if (explode5) modParts.push(`💥 ${game.i18n.localize("OATHHAMMER.Dialog.ExplodeOn5")}`)
|
||||
if (explodedCount > 0) modParts.push(`💥 ${explodedCount} ${game.i18n.localize("OATHHAMMER.Roll.Exploded")}`)
|
||||
const modLine = modParts.length ? `<div class="oh-roll-mods">${modParts.join(" · ")}</div>` : ""
|
||||
|
||||
const resultClass = successes > 0 ? "roll-success" : "roll-failure"
|
||||
const resultLabel = successes > 0
|
||||
? game.i18n.localize("OATHHAMMER.Roll.Success")
|
||||
: game.i18n.localize("OATHHAMMER.Roll.Failure")
|
||||
|
||||
const content = `
|
||||
<div class="oh-roll-card">
|
||||
<div class="oh-roll-header">${game.i18n.localize("OATHHAMMER.FreeRoll.CardTitle")}</div>
|
||||
<div class="oh-roll-info">
|
||||
<span>${colorEmoji} ${pool}d6 (${threshold}+) — ${colorLabel}</span>
|
||||
</div>
|
||||
${modLine}
|
||||
<div class="oh-roll-dice">${diceHtml}</div>
|
||||
<div class="oh-roll-result ${resultClass}">
|
||||
<span class="oh-roll-successes">${successes}</span>
|
||||
<span class="oh-roll-verdict">${resultLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
const rollMode = game.settings.get("core", "rollMode")
|
||||
const msgData = {
|
||||
speaker: ChatMessage.getSpeaker(),
|
||||
content,
|
||||
rolls,
|
||||
sound: CONFIG.sounds.dice,
|
||||
}
|
||||
ChatMessage.applyRollMode(msgData, rollMode)
|
||||
await ChatMessage.create(msgData)
|
||||
}
|
||||
@@ -126,6 +126,7 @@ export default class OathHammerRollDialog {
|
||||
const result = await foundry.applications.api.DialogV2.wait({
|
||||
window: { title },
|
||||
classes: ["fvtt-oath-hammer"],
|
||||
position: { width: 420 },
|
||||
content,
|
||||
rejectClose: false,
|
||||
buttons: [
|
||||
|
||||
@@ -108,6 +108,9 @@ export default class OathHammerActorSheet extends HandlebarsApplicationMixin(fou
|
||||
_onDragOver(event) {}
|
||||
|
||||
async _onDropItem(item) {
|
||||
// Ignore drops of items already owned by this actor (internal drag = no-op)
|
||||
if (item.parent?.id === this.document.id) return
|
||||
|
||||
const itemData = item.toObject()
|
||||
// Class is unique: replace any existing item of the same type
|
||||
if (item.type === "class") {
|
||||
|
||||
@@ -35,6 +35,8 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet {
|
||||
rollArmorSave: OathHammerCharacterSheet.#onRollArmorSave,
|
||||
resetMiracleBlocked: OathHammerCharacterSheet.#onResetMiracleBlocked,
|
||||
rollInitiative: OathHammerCharacterSheet.#onRollInitiative,
|
||||
adjustQty: OathHammerCharacterSheet.#onAdjustQty,
|
||||
adjustCurrency: OathHammerCharacterSheet.#onAdjustCurrency,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -109,6 +111,8 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet {
|
||||
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,
|
||||
_boonText: _stripHtml(o.system.boon, 80),
|
||||
_baneText: _stripHtml(o.system.bane, 80),
|
||||
_descTooltip: _stripHtml(parts.join(" "))
|
||||
}
|
||||
})
|
||||
@@ -184,9 +188,12 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet {
|
||||
}
|
||||
})
|
||||
context.ammunition = doc.itemTypes.ammunition
|
||||
// Slot tracking: max = 10 + (Might rank × 2); used = sum of all items' slots
|
||||
// Slot tracking: max = 10 + (Might rank × 2); used = sum of all items' slots × quantity
|
||||
context.slotsMax = 10 + (doc.system.attributes.might.rank * 2)
|
||||
context.slotsUsed = doc.items.reduce((sum, item) => sum + (item.system.slots ?? 0), 0)
|
||||
context.slotsUsed = doc.items.reduce((sum, item) => {
|
||||
const qty = item.system.quantity ?? 1
|
||||
return sum + (item.system.slots ?? 0) * Math.max(qty, 1)
|
||||
}, 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)
|
||||
@@ -214,7 +221,10 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet {
|
||||
_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.slotsUsed = doc.items.reduce((sum, item) => {
|
||||
const qty = item.system.quantity ?? 1
|
||||
return sum + (item.system.slots ?? 0) * Math.max(qty, 1)
|
||||
}, 0)
|
||||
context.slotsOver = context.slotsUsed > context.slotsMax
|
||||
break
|
||||
case "notes":
|
||||
@@ -376,6 +386,24 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet {
|
||||
await rollInitiativeCheck(actor)
|
||||
}
|
||||
}
|
||||
|
||||
static async #onAdjustQty(event, target) {
|
||||
const itemId = target.dataset.itemId
|
||||
const delta = parseInt(target.dataset.delta, 10)
|
||||
if (!itemId || isNaN(delta)) return
|
||||
const item = this.document.items.get(itemId)
|
||||
if (!item) return
|
||||
const current = item.system.quantity ?? 0
|
||||
await item.update({ "system.quantity": Math.max(0, current + delta) })
|
||||
}
|
||||
|
||||
static async #onAdjustCurrency(event, target) {
|
||||
const field = target.dataset.field
|
||||
const delta = parseInt(target.dataset.delta, 10)
|
||||
if (!field || isNaN(delta)) return
|
||||
const current = foundry.utils.getProperty(this.document, field) ?? 0
|
||||
await this.document.update({ [field]: Math.max(0, current + delta) })
|
||||
}
|
||||
}
|
||||
|
||||
/** Strip HTML tags and collapse whitespace for use in data-tooltip attributes. */
|
||||
|
||||
@@ -19,7 +19,7 @@ export default class OathHammerWeaponDialog {
|
||||
const sys = weapon.system
|
||||
const actorSys = actor.system
|
||||
|
||||
const isRanged = !sys.usesMight && (sys.shortRange > 0 || sys.longRange > 0)
|
||||
const isRanged = sys.proficiencyGroup === "bows" || sys.proficiencyGroup === "throwing"
|
||||
const skillKey = isRanged ? "shooting" : "fighting"
|
||||
const skillDef = SYSTEM.SKILLS[skillKey]
|
||||
const defaultAttr = skillDef.attribute
|
||||
@@ -30,6 +30,16 @@ export default class OathHammerWeaponDialog {
|
||||
|
||||
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
|
||||
@@ -56,12 +66,11 @@ export default class OathHammerWeaponDialog {
|
||||
return { value: v, label: v > 0 ? `+${v}` : String(v), selected: v === 0 }
|
||||
})
|
||||
|
||||
const rangeOptions = [
|
||||
{ value: 0, label: game.i18n.localize("OATHHAMMER.Dialog.RangeNormal") },
|
||||
{ value: -1, label: game.i18n.localize("OATHHAMMER.Dialog.RangeLong") + " (−1)" },
|
||||
{ value: -2, label: game.i18n.localize("OATHHAMMER.Dialog.RangeMoving") + " (−2)" },
|
||||
{ value: -2, label: game.i18n.localize("OATHHAMMER.Dialog.RangeConcealment") + " (−2)" },
|
||||
{ value: -3, label: game.i18n.localize("OATHHAMMER.Dialog.RangeCover") + " (−3)" },
|
||||
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)
|
||||
@@ -96,10 +105,13 @@ export default class OathHammerWeaponDialog {
|
||||
apValue: sys.ap,
|
||||
traits: traitLabels,
|
||||
attackBonusOptions,
|
||||
rangeOptions,
|
||||
rangeConditions,
|
||||
colorOptions: _colorOptions(skillColor),
|
||||
rollModes,
|
||||
visibility: game.settings.get("core", "rollMode"),
|
||||
availableLuck,
|
||||
isHuman,
|
||||
luckOptions,
|
||||
}
|
||||
|
||||
const content = await foundry.applications.handlebars.renderTemplate(
|
||||
@@ -110,6 +122,7 @@ export default class OathHammerWeaponDialog {
|
||||
const result = await foundry.applications.api.DialogV2.wait({
|
||||
window: { title: game.i18n.format("OATHHAMMER.Dialog.AttackTitle", { weapon: weapon.name }) },
|
||||
classes: ["fvtt-oath-hammer"],
|
||||
position: { width: 420 },
|
||||
content,
|
||||
rejectClose: false,
|
||||
buttons: [{
|
||||
@@ -128,12 +141,17 @@ export default class OathHammerWeaponDialog {
|
||||
if (!result) return null
|
||||
return {
|
||||
attackBonus: parseInt(result.attackBonus) || 0,
|
||||
rangeCondition: parseInt(result.rangeCondition) || 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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +190,16 @@ export default class OathHammerWeaponDialog {
|
||||
.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"
|
||||
|
||||
@@ -229,6 +257,9 @@ export default class OathHammerWeaponDialog {
|
||||
colorOptions: _colorOptions(defaultDefenseColor),
|
||||
rollModes,
|
||||
visibility: game.settings.get("core", "rollMode"),
|
||||
availableLuck,
|
||||
isHuman,
|
||||
luckOptions,
|
||||
}
|
||||
|
||||
const content = await foundry.applications.handlebars.renderTemplate(
|
||||
@@ -239,6 +270,7 @@ export default class OathHammerWeaponDialog {
|
||||
const result = await foundry.applications.api.DialogV2.wait({
|
||||
window: { title: game.i18n.format("OATHHAMMER.Dialog.WeaponDefenseTitle", { weapon: weapon.name }) },
|
||||
classes: ["fvtt-oath-hammer"],
|
||||
position: { width: 420 },
|
||||
content,
|
||||
rejectClose: false,
|
||||
buttons: [{
|
||||
@@ -279,6 +311,8 @@ export default class OathHammerWeaponDialog {
|
||||
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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,6 +377,7 @@ export default class OathHammerWeaponDialog {
|
||||
const result = await foundry.applications.api.DialogV2.wait({
|
||||
window: { title: game.i18n.format("OATHHAMMER.Dialog.DamageTitle", { weapon: weapon.name }) },
|
||||
classes: ["fvtt-oath-hammer"],
|
||||
position: { width: 420 },
|
||||
content,
|
||||
rejectClose: false,
|
||||
buttons: [{
|
||||
|
||||
@@ -206,6 +206,7 @@ export const TRAIT_TYPE_CHOICES = {
|
||||
// When a trait's uses reset (none = passive/always on)
|
||||
export const TRAIT_USAGE_PERIOD = {
|
||||
none: "OATHHAMMER.UsagePeriod.None",
|
||||
round: "OATHHAMMER.UsagePeriod.Round",
|
||||
encounter: "OATHHAMMER.UsagePeriod.Encounter",
|
||||
day: "OATHHAMMER.UsagePeriod.Day"
|
||||
}
|
||||
@@ -318,7 +319,7 @@ export const SKILLS = {
|
||||
diplomacy: { id: "diplomacy", attribute: "willpower", label: "OATHHAMMER.Skill.Diplomacy" },
|
||||
discipline: { id: "discipline", attribute: "willpower", label: "OATHHAMMER.Skill.Discipline" },
|
||||
fighting: { id: "fighting", attribute: "might", label: "OATHHAMMER.Skill.Fighting" },
|
||||
folklore: { id: "folklore", attribute: "fate", label: "OATHHAMMER.Skill.Folklore" },
|
||||
folklore: { id: "folklore", attribute: "willpower", label: "OATHHAMMER.Skill.Folklore" },
|
||||
fortune: { id: "fortune", attribute: "fate", label: "OATHHAMMER.Skill.Fortune" },
|
||||
heal: { id: "heal", attribute: "intelligence", label: "OATHHAMMER.Skill.Heal" },
|
||||
leadership: { id: "leadership", attribute: "willpower", label: "OATHHAMMER.Skill.Leadership" },
|
||||
@@ -340,9 +341,9 @@ export const SKILLS_BY_ATTRIBUTE = {
|
||||
might: ["athletics", "fighting", "masonry", "smithing"],
|
||||
toughness: ["resilience"],
|
||||
agility: ["acrobatics", "carpentry", "defense", "dexterity", "ride", "shooting", "stealth"],
|
||||
willpower: ["animalHandling", "diplomacy", "discipline", "leadership", "magic", "perception", "survival"],
|
||||
willpower: ["animalHandling", "diplomacy", "discipline", "folklore", "leadership", "magic", "perception", "survival"],
|
||||
intelligence: ["academics", "brewing", "heal", "orientation", "tracking"],
|
||||
fate: ["folklore", "fortune"],
|
||||
fate: ["fortune"],
|
||||
}
|
||||
|
||||
export const ASCII = `
|
||||
|
||||
@@ -13,10 +13,8 @@ export default class OathHammerWeapon extends foundry.abstract.TypeDataModel {
|
||||
required: true, initial: "common", choices: SYSTEM.WEAPON_PROFICIENCY_GROUPS
|
||||
})
|
||||
|
||||
// Damage: melee = Might rank + damageMod dice; bows = baseDice (fixed, no Might)
|
||||
// usesMight=true → formula displayed as "M+2", "M-1", etc.
|
||||
// usesMight=false → formula displayed as e.g. "6" (fixed dice for bows)
|
||||
schema.usesMight = new fields.BooleanField({ required: true, initial: true })
|
||||
// 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 })
|
||||
|
||||
// AP (Armor Penetration): penalty imposed on armor/defense rolls
|
||||
@@ -71,9 +69,16 @@ export default class OathHammerWeapon extends foundry.abstract.TypeDataModel {
|
||||
const map = { 0: "common", 1: "uncommon", 2: "rare", 3: "very-rare", 4: "legendary", 5: "legendary", 6: "legendary" }
|
||||
source.rarity = map[source.rarity] ?? "common"
|
||||
}
|
||||
// Remove legacy usesMight field — now derived from proficiencyGroup
|
||||
delete source.usesMight
|
||||
return super.migrateData(source)
|
||||
}
|
||||
|
||||
/** Derived: only bows skip Might for damage. Throwing weapons keep Might (arm strength). */
|
||||
get usesMight() {
|
||||
return this.proficiencyGroup !== "bows"
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable damage formula for display, e.g. "M+2", "M-1", "6"
|
||||
*/
|
||||
|
||||
@@ -183,7 +183,7 @@ export async function rollRarityCheck(actor, rarityKey, itemName) {
|
||||
* @param {number} threshold Minimum value to count as a success
|
||||
* @returns {Promise<{roll: Roll, successes: number, diceResults: Array}>}
|
||||
*/
|
||||
async function _rollPool(pool, threshold, explodeOn5 = false) {
|
||||
export async function _rollPool(pool, threshold, explodeOn5 = false) {
|
||||
const explodeThreshold = explodeOn5 ? 5 : 6
|
||||
const roll = await new Roll(`${Math.max(pool, 1)}d6`).evaluate()
|
||||
const rolls = [roll]
|
||||
@@ -216,7 +216,7 @@ async function _rollPool(pool, threshold, explodeOn5 = false) {
|
||||
/**
|
||||
* Render dice results as HTML spans.
|
||||
*/
|
||||
function _diceHtml(diceResults, threshold) {
|
||||
export function _diceHtml(diceResults, threshold) {
|
||||
return diceResults.map(({ val, exploded }) => {
|
||||
const cssClass = val >= threshold ? "die-success" : "die-fail"
|
||||
return `<span class="oh-die ${cssClass}${exploded ? " die-exploded" : ""}" title="${exploded ? "💥" : ""}">${val}</span>`
|
||||
@@ -236,12 +236,12 @@ function _diceHtml(diceResults, threshold) {
|
||||
* @param {object} options From OathHammerWeaponDialog.promptAttack()
|
||||
*/
|
||||
export async function rollWeaponAttack(actor, weapon, options = {}) {
|
||||
const { attackBonus = 0, rangeCondition = 0, attrOverride, colorOverride, visibility, autoAttackBonus = 0, explodeOn5 = false } = options
|
||||
const { attackBonus = 0, rangeCondition = 0, attrOverride, colorOverride, visibility, autoAttackBonus = 0, explodeOn5 = false, luckSpend = 0, luckIsHuman = false } = options
|
||||
|
||||
const sys = weapon.system
|
||||
const actorSys = actor.system
|
||||
|
||||
const isRanged = !sys.usesMight && (sys.shortRange > 0 || sys.longRange > 0)
|
||||
const isRanged = sys.proficiencyGroup === "bows" || sys.proficiencyGroup === "throwing"
|
||||
const skillKey = isRanged ? "shooting" : "fighting"
|
||||
const skillDef = SYSTEM.SKILLS[skillKey]
|
||||
const defaultAttr = skillDef.attribute
|
||||
@@ -254,7 +254,13 @@ export async function rollWeaponAttack(actor, weapon, options = {}) {
|
||||
const threshold = colorType === "black" ? 2 : colorType === "red" ? 3 : 4
|
||||
const colorEmoji = colorType === "black" ? "⬛" : colorType === "red" ? "🔴" : "⬜"
|
||||
|
||||
const totalDice = Math.max(attrRank + skillRank + attackBonus + rangeCondition + autoAttackBonus, 1)
|
||||
const luckDicePerPoint = luckIsHuman ? 3 : 2
|
||||
const totalDice = Math.max(attrRank + skillRank + attackBonus + rangeCondition + autoAttackBonus + (luckSpend * luckDicePerPoint), 1)
|
||||
|
||||
if (luckSpend > 0) {
|
||||
const currentLuck = actorSys.luck?.value ?? 0
|
||||
await actor.update({ "system.luck.value": Math.max(0, currentLuck - luckSpend) })
|
||||
}
|
||||
|
||||
const { roll, rolls, successes, diceResults } = await _rollPool(totalDice, threshold, explodeOn5)
|
||||
|
||||
@@ -266,7 +272,9 @@ export async function rollWeaponAttack(actor, weapon, options = {}) {
|
||||
if (attackBonus !== 0) modParts.push(`${attackBonus > 0 ? "+" : ""}${attackBonus} ${game.i18n.localize("OATHHAMMER.Dialog.AttackModifier")}`)
|
||||
if (rangeCondition !== 0) modParts.push(`${rangeCondition} ${game.i18n.localize("OATHHAMMER.Dialog.RangeCondition")}`)
|
||||
if (autoAttackBonus > 0) modParts.push(`+${autoAttackBonus} auto`)
|
||||
if (explodeOn5) modParts.push(`💥 ${game.i18n.localize("OATHHAMMER.Dialog.ExplodeOn5")}`)
|
||||
if (luckSpend > 0) modParts.push(`+${luckSpend * luckDicePerPoint} ${game.i18n.localize("OATHHAMMER.Dialog.LuckSpend")} (${luckSpend}LP${luckIsHuman ? " 👤" : ""})`)
|
||||
const explodedCount = diceResults.filter(d => d.exploded).length
|
||||
if (explodedCount > 0) modParts.push(`💥 ${explodedCount} ${game.i18n.localize("OATHHAMMER.Roll.Exploded")}`)
|
||||
const modLine = modParts.length ? `<div class="oh-roll-mods">${modParts.join(" · ")}</div>` : ""
|
||||
|
||||
const content = `
|
||||
@@ -342,6 +350,8 @@ export async function rollWeaponDamage(actor, weapon, options = {}) {
|
||||
if (sv > 0) modParts.push(`+${sv} SV`)
|
||||
if (damageBonus !== 0) modParts.push(`${damageBonus > 0 ? "+" : ""}${damageBonus} ${game.i18n.localize("OATHHAMMER.Dialog.DamageModifier")}`)
|
||||
if (autoDamageBonus > 0) modParts.push(`+${autoDamageBonus} auto`)
|
||||
const explodedCount = diceResults.filter(d => d.exploded).length
|
||||
if (explodedCount > 0) modParts.push(`💥 ${explodedCount} ${game.i18n.localize("OATHHAMMER.Roll.Exploded")}`)
|
||||
const modLine = modParts.length ? `<div class="oh-roll-mods">${modParts.join(" · ")}</div>` : ""
|
||||
|
||||
const apNote = sys.ap > 0 ? `<span class="oh-ap-note">AP ${sys.ap}</span>` : ""
|
||||
@@ -445,7 +455,8 @@ export async function rollSpellCast(actor, spell, options = {}) {
|
||||
if (poolPenalty !== 0) modParts.push(`${poolPenalty} ${game.i18n.localize("OATHHAMMER.Enhancement." + _cap(enhancement))}`)
|
||||
if (elementalBonus > 0) modParts.push(`+${elementalBonus} ${game.i18n.localize("OATHHAMMER.Dialog.ElementMet")}`)
|
||||
if (grimPenalty < 0) modParts.push(`${grimPenalty} ${game.i18n.localize("OATHHAMMER.Dialog.GrimoireNo")}`)
|
||||
if (explodeOn5) modParts.push(`💥 ${game.i18n.localize("OATHHAMMER.Dialog.ExplodeOn5")}`)
|
||||
const explodedCountSpell = diceResults.filter(d => d.exploded).length
|
||||
if (explodedCountSpell > 0) modParts.push(`💥 ${explodedCountSpell} ${game.i18n.localize("OATHHAMMER.Roll.Exploded")}`)
|
||||
const modLine = modParts.length ? `<div class="oh-roll-mods">${modParts.join(" · ")}</div>` : ""
|
||||
|
||||
const stressLine = `<div class="oh-stress-line${isBlocked ? " stress-blocked" : ""}">
|
||||
@@ -530,7 +541,8 @@ export async function rollMiracleCast(actor, miracle, options = {}) {
|
||||
|
||||
const modParts = []
|
||||
if (bonus !== 0) modParts.push(`${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}`)
|
||||
if (explodeOn5) modParts.push(`💥 ${game.i18n.localize("OATHHAMMER.Dialog.ExplodeOn5")}`)
|
||||
const explodedCountMiracle = diceResults.filter(d => d.exploded).length
|
||||
if (explodedCountMiracle > 0) modParts.push(`💥 ${explodedCountMiracle} ${game.i18n.localize("OATHHAMMER.Roll.Exploded")}`)
|
||||
const modLine = modParts.length ? `<div class="oh-roll-mods">${modParts.join(" · ")}</div>` : ""
|
||||
|
||||
const blockedLine = !isSuccess
|
||||
@@ -618,6 +630,8 @@ export async function rollDefense(actor, options = {}) {
|
||||
if (traitBonus > 0) modParts.push(`+${traitBonus} ${game.i18n.localize(attackType === "melee" ? "OATHHAMMER.WeaponTrait.Parry" : "OATHHAMMER.WeaponTrait.Block")}`)
|
||||
if (armorPenalty < 0) modParts.push(`${armorPenalty} ${game.i18n.localize("OATHHAMMER.Dialog.ArmorPenalty")}`)
|
||||
if (bonus !== 0) modParts.push(`${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}`)
|
||||
const explodedCountDef = diceResults.filter(d => d.exploded).length
|
||||
if (explodedCountDef > 0) modParts.push(`💥 ${explodedCountDef} ${game.i18n.localize("OATHHAMMER.Roll.Exploded")}`)
|
||||
const modLine = modParts.length ? `<div class="oh-roll-mods">${modParts.join(" · ")}</div>` : ""
|
||||
|
||||
const content = `
|
||||
@@ -685,13 +699,21 @@ export async function rollWeaponDefense(actor, weapon, options = {}) {
|
||||
bonus = 0,
|
||||
visibility,
|
||||
explodeOn5 = false,
|
||||
luckSpend = 0,
|
||||
luckIsHuman = false,
|
||||
} = options
|
||||
|
||||
const defRank = actor.system.skills.defense.rank
|
||||
const totalDice = Math.max(attrRank + defRank + traitBonus + armorPenalty + diminishPenalty + bonus, 1)
|
||||
const defRank = actor.system.skills.defense.rank
|
||||
const luckDicePerPoint = luckIsHuman ? 3 : 2
|
||||
const totalDice = Math.max(attrRank + defRank + traitBonus + armorPenalty + diminishPenalty + bonus + (luckSpend * luckDicePerPoint), 1)
|
||||
const threshold = colorOverride === "black" ? 2 : colorOverride === "red" ? 3 : 4
|
||||
const colorEmoji = colorOverride === "black" ? "⬛" : colorOverride === "red" ? "🔴" : "⬜"
|
||||
|
||||
if (luckSpend > 0) {
|
||||
const currentLuck = actor.system.luck?.value ?? 0
|
||||
await actor.update({ "system.luck.value": Math.max(0, currentLuck - luckSpend) })
|
||||
}
|
||||
|
||||
const { roll, rolls, successes, diceResults } = await _rollPool(totalDice, threshold, explodeOn5)
|
||||
const diceHtml = _diceHtml(diceResults, threshold)
|
||||
|
||||
@@ -704,7 +726,9 @@ export async function rollWeaponDefense(actor, weapon, options = {}) {
|
||||
if (armorPenalty < 0) modParts.push(`${armorPenalty} ${game.i18n.localize("OATHHAMMER.Dialog.ArmorPenalty")}`)
|
||||
if (diminishPenalty < 0) modParts.push(`${diminishPenalty} ${game.i18n.localize("OATHHAMMER.Dialog.DiminishingDefense")}`)
|
||||
if (bonus !== 0) modParts.push(`${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}`)
|
||||
if (explodeOn5) modParts.push(`💥 ${game.i18n.localize("OATHHAMMER.Dialog.ExplodeOn5")}`)
|
||||
if (luckSpend > 0) modParts.push(`+${luckSpend * luckDicePerPoint} ${game.i18n.localize("OATHHAMMER.Dialog.LuckSpend")} (${luckSpend}LP${luckIsHuman ? " 👤" : ""})`)
|
||||
const explodedCountWDef = diceResults.filter(d => d.exploded).length
|
||||
if (explodedCountWDef > 0) modParts.push(`💥 ${explodedCountWDef} ${game.i18n.localize("OATHHAMMER.Roll.Exploded")}`)
|
||||
const modLine = modParts.length ? `<div class="oh-roll-mods">${modParts.join(" · ")}</div>` : ""
|
||||
|
||||
const content = `
|
||||
@@ -761,24 +785,34 @@ export async function rollArmorSave(actor, armor, options = {}) {
|
||||
bonus = 0,
|
||||
visibility,
|
||||
explodeOn5 = false,
|
||||
luckSpend = 0,
|
||||
luckIsHuman = false,
|
||||
} = options
|
||||
|
||||
// Armor CAN be reduced to 0 dice (fully bypassed by AP)
|
||||
const totalDice = Math.max(av + apPenalty + bonus, 0)
|
||||
const luckDicePerPoint = luckIsHuman ? 3 : 2
|
||||
// Armor CAN be reduced to 0 dice (fully bypassed by AP) — luck can still rescue
|
||||
const totalDice = Math.max(av + apPenalty + bonus + (luckSpend * luckDicePerPoint), 0)
|
||||
const colorType = colorOverride || (isReinforced ? "red" : "white")
|
||||
const threshold = colorType === "black" ? 2 : colorType === "red" ? 3 : 4
|
||||
const colorEmoji = colorType === "black" ? "⬛" : colorType === "red" ? "🔴" : "⬜"
|
||||
|
||||
if (luckSpend > 0) {
|
||||
const currentLuck = actor.system.luck?.value ?? 0
|
||||
await actor.update({ "system.luck.value": Math.max(0, currentLuck - luckSpend) })
|
||||
}
|
||||
|
||||
let successes = 0
|
||||
let diceHtml = `<em>${game.i18n.localize("OATHHAMMER.Roll.ArmorBypassed")}</em>`
|
||||
let roll
|
||||
let rolls = []
|
||||
let armorDiceResults = []
|
||||
|
||||
if (totalDice > 0) {
|
||||
const result = await _rollPool(totalDice, threshold, explodeOn5)
|
||||
roll = result.roll
|
||||
rolls = result.rolls
|
||||
successes = result.successes
|
||||
armorDiceResults = result.diceResults
|
||||
diceHtml = _diceHtml(result.diceResults, threshold)
|
||||
} else {
|
||||
// Zero dice — create a dummy roll with no results so Foundry can still attach it
|
||||
@@ -790,7 +824,9 @@ export async function rollArmorSave(actor, armor, options = {}) {
|
||||
const modParts = []
|
||||
if (apPenalty < 0) modParts.push(`AP ${apPenalty}`)
|
||||
if (bonus !== 0) modParts.push(`${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}`)
|
||||
if (explodeOn5) modParts.push(`💥 ${game.i18n.localize("OATHHAMMER.Dialog.ExplodeOn5")}`)
|
||||
if (luckSpend > 0) modParts.push(`+${luckSpend * luckDicePerPoint} ${game.i18n.localize("OATHHAMMER.Dialog.LuckSpend")} (${luckSpend}LP${luckIsHuman ? " 👤" : ""})`)
|
||||
const explodedCountArmor = armorDiceResults.filter(d => d.exploded).length
|
||||
if (explodedCountArmor > 0) modParts.push(`💥 ${explodedCountArmor} ${game.i18n.localize("OATHHAMMER.Roll.Exploded")}`)
|
||||
const modLine = modParts.length ? `<div class="oh-roll-mods">${modParts.join(" · ")}</div>` : ""
|
||||
|
||||
const content = `
|
||||
@@ -866,7 +902,8 @@ export async function rollInitiativeCheck(actor, options = {}) {
|
||||
|
||||
const modParts = []
|
||||
if (bonus !== 0) modParts.push(`${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}`)
|
||||
if (explodeOn5) modParts.push(`💥 ${game.i18n.localize("OATHHAMMER.Dialog.ExplodeOn5")}`)
|
||||
const explodedCountInit = diceResults.filter(d => d.exploded).length
|
||||
if (explodedCountInit > 0) modParts.push(`💥 ${explodedCountInit} ${game.i18n.localize("OATHHAMMER.Roll.Exploded")}`)
|
||||
const modLine = modParts.length ? `<div class="oh-roll-mods">${modParts.join(" · ")}</div>` : ""
|
||||
|
||||
const content = `
|
||||
|
||||
Reference in New Issue
Block a user