feat: luck bonus, inventory slots bonus, multi-enhancements, magic skill modifier
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)
This commit is contained in:
2026-05-12 08:16:57 +02:00
parent f67d9079dd
commit 0381e8e024
21 changed files with 192 additions and 39 deletions
+57 -3
View File
@@ -565,16 +565,19 @@
opacity: 0.7;
font-size: calc(0.86rem * 0.9);
}
.oathhammer .character-main .character-stats-band .character-resources .character-resource .grit-max-group {
.oathhammer .character-main .character-stats-band .character-resources .character-resource .grit-max-group,
.oathhammer .character-main .character-stats-band .character-resources .character-resource .luck-max-group {
display: flex;
align-items: center;
gap: 2px;
}
.oathhammer .character-main .character-stats-band .character-resources .character-resource .grit-max-group .res-bonus-label {
.oathhammer .character-main .character-stats-band .character-resources .character-resource .grit-max-group .res-bonus-label,
.oathhammer .character-main .character-stats-band .character-resources .character-resource .luck-max-group .res-bonus-label {
opacity: 0.6;
font-size: calc(0.86rem * 0.9);
}
.oathhammer .character-main .character-stats-band .character-resources .character-resource .grit-max-group .res-bonus-input {
.oathhammer .character-main .character-stats-band .character-resources .character-resource .grit-max-group .res-bonus-input,
.oathhammer .character-main .character-stats-band .character-resources .character-resource .luck-max-group .res-bonus-input {
width: 2.2rem;
opacity: 0.85;
border-left: 1px dashed #535128;
@@ -1327,6 +1330,7 @@
}
.slots-counter {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 6px 4px;
}
@@ -1353,6 +1357,20 @@
background: rgba(192, 57, 43, 0.1);
border-color: rgba(192, 57, 43, 0.4);
}
.slots-counter .slots-bonus-label {
font-size: calc(0.86rem * 0.9);
color: rgba(42, 26, 10, 0.6);
margin-left: 6px;
}
.slots-counter .slots-bonus-input {
width: 3rem;
font-size: calc(0.86rem * 0.9);
text-align: center;
padding: 1px 4px;
border: 1px solid rgba(200, 168, 75, 0.4);
border-radius: 4px;
background: rgba(200, 168, 75, 0.08);
}
.oathhammer .item-list--regiment .item-list-header,
.oathhammer .item-list--regiment .item-entry {
grid-template-columns: 24px 1fr 4rem 5rem 4rem 4.5rem;
@@ -2360,6 +2378,42 @@
.oh-miracle-dialog select.enhancement-select {
min-width: 220px;
}
.oh-spell-dialog .roll-option-enhancements,
.oh-miracle-dialog .roll-option-enhancements {
align-items: flex-start;
}
.oh-spell-dialog .roll-option-enhancements .enhancements-header,
.oh-miracle-dialog .roll-option-enhancements .enhancements-header {
padding-top: 3px;
flex-shrink: 0;
}
.oh-spell-dialog .roll-option-enhancements .enhancements-list,
.oh-miracle-dialog .roll-option-enhancements .enhancements-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.oh-spell-dialog .roll-option-enhancements .enhancement-item,
.oh-miracle-dialog .roll-option-enhancements .enhancement-item {
display: flex;
align-items: center;
gap: 6px;
}
.oh-spell-dialog .roll-option-enhancements .enhancement-item input[type="checkbox"],
.oh-miracle-dialog .roll-option-enhancements .enhancement-item input[type="checkbox"] {
flex-shrink: 0;
width: 14px;
height: 14px;
cursor: pointer;
}
.oh-spell-dialog .roll-option-enhancements .enhancement-item .enh-name,
.oh-miracle-dialog .roll-option-enhancements .enhancement-item .enh-name {
font-size: calc(0.86rem * 0.85);
color: #2a1a0a;
cursor: pointer;
margin: 0;
font-weight: normal;
}
.oh-spell-card .oh-stress-line,
.oh-miracle-card .oh-stress-line {
margin-top: 6px;
+1
View File
@@ -253,6 +253,7 @@
"Armor": "Armor",
"Ammunition": "Ammunition",
"ItemSlots": "Item Slots",
"SlotsBonus": "Bonus Slots",
"Spells": "Spells",
"Miracles": "Miracles",
"Equipment": "Equipment",
+2 -1
View File
@@ -169,7 +169,8 @@
.res-sep { opacity: 0.7; font-size: @font-size-xs; }
.grit-max-group {
.grit-max-group,
.luck-max-group {
display: flex;
align-items: center;
gap: 2px;
+17
View File
@@ -302,6 +302,7 @@
// Slots counter on the Combat tab
.slots-counter {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 6px 4px;
@@ -330,6 +331,22 @@
border-color: fade(#c0392b, 40%);
}
}
.slots-bonus-label {
font-size: @font-size-xs;
color: fade(@color-dark, 60%);
margin-left: 6px;
}
.slots-bonus-input {
width: 3rem;
font-size: @font-size-xs;
text-align: center;
padding: 1px 4px;
border: 1px solid fade(@color-gold, 40%);
border-radius: 4px;
background: fade(@color-gold, 8%);
}
}
// ── Regiment list (settlement garrison tab) ──────────────────────────────────
+38 -1
View File
@@ -553,10 +553,47 @@
i { flex-shrink: 0; }
}
// Wide select for enhancement list
// Wide select for enhancement list (legacy, kept for miracle dialog)
select.enhancement-select {
min-width: 220px;
}
// Multi-enhancement checkbox list
.roll-option-enhancements {
align-items: flex-start;
.enhancements-header {
padding-top: 3px;
flex-shrink: 0;
}
.enhancements-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.enhancement-item {
display: flex;
align-items: center;
gap: 6px;
input[type="checkbox"] {
flex-shrink: 0;
width: 14px;
height: 14px;
cursor: pointer;
}
.enh-name {
font-size: @font-size-sm;
color: @color-dark;
cursor: pointer;
margin: 0;
font-weight: normal;
}
}
}
}
// Chat card additions for spell/miracle
+5 -1
View File
@@ -8,8 +8,10 @@ export default class OathHammerMiracleDialog {
const wpRank = actorSys.attributes.willpower.rank
const magicRank = actorSys.skills.magic.rank
const magicMod = actorSys.skills.magic.modifier ?? 0
const magicColor = actorSys.skills.magic.colorDiceType ?? "white"
const basePool = wpRank + magicRank
const basePool = wpRank + magicRank + magicMod
const magicModDisplay = magicMod > 0 ? `+${magicMod}` : magicMod < 0 ? `${magicMod}` : ""
const isRitual = sys.isRitual
const dv = isRitual ? (sys.difficultyValue || 1) : null
@@ -60,6 +62,8 @@ export default class OathHammerMiracleDialog {
spellSave: sys.spellSave,
wpRank,
magicRank,
magicMod,
magicModDisplay,
basePool,
miracleCountOptions,
colorOptions,
@@ -39,6 +39,7 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet {
adjustCurrency: OathHammerCharacterSheet.#onAdjustCurrency,
adjustLuck: OathHammerCharacterSheet.#onAdjustLuck,
adjustGrit: OathHammerCharacterSheet.#onAdjustGrit,
adjustStress: OathHammerCharacterSheet.#onAdjustStress,
clearStress: OathHammerCharacterSheet.#onClearStress,
},
}
@@ -190,8 +191,8 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet {
}
})
context.ammunition = doc.itemTypes.ammunition
// Slot tracking: max = 10 + (Might rank × 2); used = sum of all items' slots × quantity
context.slotsMax = 10 + (doc.system.attributes.might.rank * 2)
// Slot tracking: max = 10 + (Might rank × 2) + bonus; used = sum of all items' slots × quantity
context.slotsMax = 10 + (doc.system.attributes.might.rank * 2) + (doc.system.inventory?.slotsBonus ?? 0)
context.slotsUsed = doc.items.reduce((sum, item) => {
const qty = item.system.quantity ?? 1
return sum + (item.system.slots ?? 0) * Math.max(qty, 1)
@@ -225,7 +226,7 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet {
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.slotsMax = 10 + (doc.system.attributes.might.rank * 2) + (doc.system.inventory?.slotsBonus ?? 0)
context.slotsUsed = doc.items.reduce((sum, item) => {
const qty = item.system.quantity ?? 1
return sum + (item.system.slots ?? 0) * Math.max(qty, 1)
+27 -13
View File
@@ -27,8 +27,10 @@ export default class OathHammerSpellDialog {
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
const basePool = intRank + magicRank + magicMod
const magicModDisplay = magicMod > 0 ? `+${magicMod}` : magicMod < 0 ? `${magicMod}` : ""
const currentStress = actorSys.arcaneStress.value
const stressThreshold = actorSys.arcaneStress.threshold
@@ -48,11 +50,12 @@ export default class OathHammerSpellDialog {
{ value: "black", label: game.i18n.localize("OATHHAMMER.ColorDice.Black"), selected: magicColor === "black" },
]
const enhancementOptions = Object.entries(SPELL_ENHANCEMENTS).map(([key, def]) => ({
value: key,
label: game.i18n.localize(def.label),
selected: key === "none",
}))
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
@@ -92,6 +95,8 @@ export default class OathHammerSpellDialog {
element: sys.element,
intRank,
magicRank,
magicMod,
magicModDisplay,
basePool,
poolSizeOptions,
colorOptions,
@@ -132,16 +137,25 @@ export default class OathHammerSpellDialog {
if (!result) return null
const enhKey = result.enhancement ?? "none"
const enh = SPELL_ENHANCEMENTS[enhKey] ?? SPELL_ENHANCEMENTS.none
// 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,
enhancement: enhKey,
stressCost: enh.stress,
poolPenalty: enh.penalty,
redDice: enh.redDice,
noStress: enh.noStress,
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,
+8 -3
View File
@@ -75,7 +75,8 @@ export default class OathHammerCharacter extends foundry.abstract.TypeDataModel
// Luck.max is derived from fate.rank; resets at session start.
schema.luck = new fields.SchemaField({
value: new fields.NumberField({ ...requiredInteger, initial: 1, min: 0 }),
max: new fields.NumberField({ ...requiredInteger, initial: 1, min: 0 })
max: new fields.NumberField({ ...requiredInteger, initial: 1, min: 0 }),
bonus: new fields.NumberField({ ...requiredInteger, initial: 0 })
})
schema.arcaneStress = new fields.SchemaField({
@@ -119,6 +120,10 @@ export default class OathHammerCharacter extends foundry.abstract.TypeDataModel
copper: new fields.NumberField({ ...requiredInteger, initial: 0, min: 0 })
})
schema.inventory = new fields.SchemaField({
slotsBonus: new fields.NumberField({ required: true, nullable: false, integer: true, initial: 0 }),
})
return schema
}
@@ -128,8 +133,8 @@ export default class OathHammerCharacter extends foundry.abstract.TypeDataModel
super.prepareDerivedData()
// 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
// Luck max = Fate rank + bonus; restores at session start
this.luck.max = this.attributes.fate.rank + (this.luck.bonus ?? 0)
// Defense score = 10 + Agility + Armor Rating + bonus
this.defense.value = 10 + this.attributes.agility.rank + this.defense.armorRating + this.defense.bonus
// Stress Threshold = Willpower rank + Magic rank + bonus (rulebook p.101)
+11 -7
View File
@@ -463,7 +463,7 @@ export async function rollWeaponDamage(actor, weapon, options = {}) {
export async function rollSpellCast(actor, spell, options = {}) {
const {
dv = spell.system.difficultyValue,
enhancement = "none",
enhancements = ["none"],
stressCost = 0,
poolPenalty = 0,
redDice = false,
@@ -484,8 +484,9 @@ export async function rollSpellCast(actor, spell, options = {}) {
const intRank = actorSys.attributes.intelligence.rank
const magicRank = actorSys.skills.magic.rank
const magicMod = actorSys.skills.magic.modifier ?? 0
const luckDicePerPoint = luckIsHuman ? 3 : 2
const baseDice = intRank + magicRank + bonus + poolPenalty + elementalBonus + grimPenalty + (luckSpend * luckDicePerPoint)
const baseDice = intRank + magicRank + magicMod + bonus + poolPenalty + elementalBonus + grimPenalty + (luckSpend * luckDicePerPoint)
// poolSize: voluntary reduction (p.101) — clamped to [1, baseDice]
const totalDice = poolSize !== null
? Math.max(1, Math.min(poolSize + bonus + poolPenalty + elementalBonus + grimPenalty + (luckSpend * luckDicePerPoint), baseDice))
@@ -523,9 +524,11 @@ export async function rollSpellCast(actor, spell, options = {}) {
: game.i18n.localize("OATHHAMMER.Roll.Failure")
const modParts = []
if (poolSize !== null && poolSize < intRank + magicRank) modParts.push(`🎲 ${poolSize}d ${game.i18n.localize("OATHHAMMER.Dialog.PoolSizeReduced")}`)
if (poolSize !== null && poolSize < intRank + magicRank + magicMod) modParts.push(`🎲 ${poolSize}d ${game.i18n.localize("OATHHAMMER.Dialog.PoolSizeReduced")}`)
if (bonus !== 0) modParts.push(`${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}`)
if (poolPenalty !== 0) modParts.push(`${poolPenalty} ${game.i18n.localize("OATHHAMMER.Enhancement." + _cap(enhancement))}`)
const activeEnhs = enhancements.filter(k => k !== "none")
if (poolPenalty !== 0 || (activeEnhs.length && !poolPenalty))
modParts.push(`${poolPenalty !== 0 ? poolPenalty + "d " : ""}[${activeEnhs.join(", ")}]`)
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 (luckSpend > 0) modParts.push(`+${luckSpend * luckDicePerPoint} ${game.i18n.localize("OATHHAMMER.Dialog.LuckSpend")} (${luckSpend}LP${luckIsHuman ? " 👤" : ""})`)
@@ -547,7 +550,7 @@ export async function rollSpellCast(actor, spell, options = {}) {
<span>${spell.name} (DV ${dv})</span>
</div>
<div class="oh-roll-info">
<span>${attrLabel} ${intRank} + ${skillLabel} ${magicRank}</span>
<span>${attrLabel} ${intRank} + ${skillLabel} ${magicRank}${magicMod !== 0 ? ` ${magicMod > 0 ? "+" : ""}${magicMod}` : ""}</span>
<span>${colorEmoji} ${totalDice}d6 (${threshold}+)</span>
</div>
${modLine}
@@ -603,8 +606,9 @@ export async function rollMiracleCast(actor, miracle, options = {}) {
const wpRank = actorSys.attributes.willpower.rank
const magicRank = actorSys.skills.magic.rank
const magicMod = actorSys.skills.magic.modifier ?? 0
const luckDicePerPoint = luckIsHuman ? 3 : 2
const totalDice = Math.max(wpRank + magicRank + bonus + (luckSpend * luckDicePerPoint), 1)
const totalDice = Math.max(wpRank + magicRank + magicMod + bonus + (luckSpend * luckDicePerPoint), 1)
const threshold = colorOverride === "black" ? 2 : colorOverride === "red" ? 3 : 4
const colorEmoji = colorOverride === "black" ? "⬛" : colorOverride === "red" ? "🔴" : "⬜"
@@ -646,7 +650,7 @@ export async function rollMiracleCast(actor, miracle, options = {}) {
<span>${miracle.name} (${dvNote})</span>
</div>
<div class="oh-roll-info">
<span>${attrLabel} ${wpRank} + ${skillLabel} ${magicRank}</span>
<span>${attrLabel} ${wpRank} + ${skillLabel} ${magicRank}${magicMod !== 0 ? ` ${magicMod > 0 ? "+" : ""}${magicMod}` : ""}</span>
<span>${colorEmoji} ${totalDice}d6 (${threshold}+)</span>
</div>
${modLine}
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
MANIFEST-000021
MANIFEST-000030
Binary file not shown.
View File
View File
+2
View File
@@ -2,6 +2,8 @@
<div class="slots-counter">
<span class="slots-label">{{localize "OATHHAMMER.Label.ItemSlots"}}</span>
<span class="slots-value {{#if slotsOver}}slots-over{{/if}}">{{slotsUsed}} / {{slotsMax}}</span>
<span class="slots-bonus-label">{{localize "OATHHAMMER.Label.SlotsBonus"}}</span>
<input type="number" name="system.inventory.slotsBonus" value="{{system.inventory.slotsBonus}}" class="slots-bonus-input">
</div>
<fieldset class="currency-bar">
<legend>{{localize "OATHHAMMER.Label.Currency"}}</legend>
+7 -1
View File
@@ -72,7 +72,13 @@
<a data-action="adjustLuck" data-delta="1" class="luck-btn">+</a>
</div>
<span class="res-sep">/</span>
{{formInput systemFields.luck.fields.max value=system.luck.max name="system.luck.max" disabled=isPlayMode}}
<span class="luck-max-group">
{{formInput systemFields.luck.fields.max value=system.luck.max name="system.luck.max" disabled=true}}
{{#unless isPlayMode}}
<span class="res-bonus-label">+</span>
{{formInput systemFields.luck.fields.bonus value=system.luck.bonus name="system.luck.bonus" class="res-bonus-input"}}
{{/unless}}
</span>
</div>
<div class="character-resource">
<span class="resource-label">{{localize "OATHHAMMER.Label.Movement"}}</span>
+1
View File
@@ -32,6 +32,7 @@
<div class="pool-info-line">
{{localize "OATHHAMMER.Skill.Magic"}} ({{localize "OATHHAMMER.Attribute.Willpower"}} {{wpRank}})
+ {{localize "OATHHAMMER.Label.SkillRank"}} {{magicRank}}
{{#if magicModDisplay}} {{localize "OATHHAMMER.Label.SkillModifier"}} {{magicModDisplay}}{{/if}}
= <strong>{{basePool}}d6</strong>
</div>
+11 -5
View File
@@ -31,6 +31,7 @@
<div class="pool-info-line">
{{localize "OATHHAMMER.Skill.Magic"}} ({{localize "OATHHAMMER.Attribute.Intelligence"}} {{intRank}})
+ {{localize "OATHHAMMER.Label.SkillRank"}} {{magicRank}}
{{#if magicModDisplay}} {{localize "OATHHAMMER.Label.SkillModifier"}} {{magicModDisplay}}{{/if}}
= <strong>{{basePool}}d6</strong>
</div>
@@ -52,11 +53,16 @@
<span class="roll-option-hint">{{localize "OATHHAMMER.Dialog.DiceColorHint"}}</span>
</div>
<div class="roll-option-row">
<label>{{localize "OATHHAMMER.Dialog.Enhancement"}}</label>
<select name="enhancement" class="enhancement-select">
{{#each enhancementOptions}}<option value="{{value}}"{{#if selected}} selected{{/if}}>{{label}}</option>{{/each}}
</select>
<div class="roll-option-row roll-option-enhancements">
<label class="enhancements-header">{{localize "OATHHAMMER.Dialog.Enhancement"}}</label>
<div class="enhancements-list">
{{#each enhancementOptions}}
<div class="enhancement-item">
<input type="checkbox" id="enh_{{value}}" name="enh_{{value}}" value="true">
<label for="enh_{{value}}" class="enh-name">{{label}}</label>
</div>
{{/each}}
</div>
</div>
{{#if isElemental}}