diff --git a/css/fvtt-oath-hammer.css b/css/fvtt-oath-hammer.css index 725bbcb..6f25bdc 100644 --- a/css/fvtt-oath-hammer.css +++ b/css/fvtt-oath-hammer.css @@ -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; diff --git a/lang/en.json b/lang/en.json index 7a97d5b..6004ff7 100644 --- a/lang/en.json +++ b/lang/en.json @@ -253,6 +253,7 @@ "Armor": "Armor", "Ammunition": "Ammunition", "ItemSlots": "Item Slots", + "SlotsBonus": "Bonus Slots", "Spells": "Spells", "Miracles": "Miracles", "Equipment": "Equipment", diff --git a/less/actor-sheet.less b/less/actor-sheet.less index c269f22..56714b9 100644 --- a/less/actor-sheet.less +++ b/less/actor-sheet.less @@ -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; diff --git a/less/item-list.less b/less/item-list.less index d71a634..1867585 100644 --- a/less/item-list.less +++ b/less/item-list.less @@ -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) ────────────────────────────────── diff --git a/less/roll-dialog.less b/less/roll-dialog.less index b9d3463..abaea50 100644 --- a/less/roll-dialog.less +++ b/less/roll-dialog.less @@ -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 diff --git a/module/applications/miracle-dialog.mjs b/module/applications/miracle-dialog.mjs index e230ead..6097280 100644 --- a/module/applications/miracle-dialog.mjs +++ b/module/applications/miracle-dialog.mjs @@ -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, diff --git a/module/applications/sheets/character-sheet.mjs b/module/applications/sheets/character-sheet.mjs index 404f05d..1b7ff84 100644 --- a/module/applications/sheets/character-sheet.mjs +++ b/module/applications/sheets/character-sheet.mjs @@ -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) diff --git a/module/applications/spell-dialog.mjs b/module/applications/spell-dialog.mjs index 6d4f510..49fcb56 100644 --- a/module/applications/spell-dialog.mjs +++ b/module/applications/spell-dialog.mjs @@ -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, diff --git a/module/models/character.mjs b/module/models/character.mjs index 8438d75..681adc8 100644 --- a/module/models/character.mjs +++ b/module/models/character.mjs @@ -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) diff --git a/module/rolls.mjs b/module/rolls.mjs index 40b619f..6a7769b 100644 --- a/module/rolls.mjs +++ b/module/rolls.mjs @@ -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 = {}) { ${spell.name} (DV ${dv})