Enhancements as per issue tracking sheet

This commit is contained in:
2026-03-19 15:39:25 +01:00
parent b2befe039e
commit b67d85c6be
22 changed files with 588 additions and 55 deletions

View File

@@ -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",
}
}
}

View File

@@ -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: [{

View 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 (120 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}+) &mdash; ${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)
}

View File

@@ -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: [

View File

@@ -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") {

View File

@@ -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. */

View File

@@ -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: [{