9 Commits

Author SHA1 Message Date
uberwald 0381e8e024 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)
2026-05-12 08:16:57 +02:00
uberwald f67d9079dd FIx for spells/miracles
Release Creation / build (release) Successful in 2m33s
2026-04-30 23:10:13 +02:00
uberwald ecc2c22372 Merge branch 'main' of https://www.uberwald.me/gitea/uberwald/fvtt-oath-hammer 2026-04-30 23:09:04 +02:00
uberwald 1641be6516 FIx for spells/miracles 2026-04-30 23:08:23 +02:00
uberwald ef41116006 Actualiser .gitea/workflows/release.yaml
Release Creation / build (release) Successful in 1m16s
2026-04-20 15:51:51 +02:00
uberwald 83566ba8fb Actualiser .gitea/workflows/release.yaml 2026-04-20 14:32:02 +02:00
uberwald b4211c121d Add luck option after roll, attributes above 6, fix miracle icon and grit bonus
Release Creation / build (release) Successful in 1m36s
2026-04-20 08:23:33 +02:00
uberwald 36cb3bc755 Initial release for FoundryVTT
Release Creation / build (release) Successful in 1m41s
2026-04-12 01:13:10 +02:00
uberwald d71077d033 Add map welcom page 2026-04-09 23:55:32 +02:00
31 changed files with 676 additions and 83 deletions
+13 -13
View File
@@ -26,8 +26,8 @@ jobs:
env: env:
version: ${{steps.get_version.outputs.version-without-v}} version: ${{steps.get_version.outputs.version-without-v}}
url: https://www.uberwald.me/gitea/${{gitea.repository}} url: https://www.uberwald.me/gitea/${{gitea.repository}}
manifest: https://www.uberwald.me/gitea/public/fvtt-oath-hammer/releases/download/latest/system.json manifest: https://www.uberwald.me/gitea/uberwald/fvtt-oath-hammer/releases/download/latest/system.json
download: https://www.uberwald.me/gitea/public/fvtt-oath-hammer/releases/download/${{github.event.release.tag_name}}/fvtt-oath-hammer.zip download: https://www.uberwald.me/gitea/uberwald/fvtt-oath-hammer/releases/download/${{github.event.release.tag_name}}/fvtt-oath-hammer.zip
# Build CSS from LESS sources # Build CSS from LESS sources
- name: Setup Node.js - name: Setup Node.js
@@ -45,7 +45,7 @@ jobs:
apt update -y apt update -y
apt install -y zip apt install -y zip
- run: zip -r ./fvtt-oath-hammer.zip system.json README.md LICENSE.md assets/ css/ lang/ module/ oath-hammer.mjs templates/ - run: zip -r ./fvtt-oath-hammer.zip system.json README.md LICENSE.md assets/ css/ lang/ module/ oath-hammer.mjs packs/ templates/
- name: setup go - name: setup go
uses: https://github.com/actions/setup-go@v4 uses: https://github.com/actions/setup-go@v4
@@ -61,13 +61,13 @@ jobs:
system.json system.json
api_key: "${{secrets.ALLOW_PUSH_RELEASE}}" api_key: "${{secrets.ALLOW_PUSH_RELEASE}}"
#- name: Publish to Foundry server - name: Publish to Foundry server
#uses: https://github.com/djlechuck/foundryvtt-publish-package-action@v1 uses: https://github.com/djlechuck/foundryvtt-publish-package-action@v1
#with: with:
#token: ${{ secrets.FOUNDRYVTT_RELEASE_TOKEN }} token: ${{ secrets.FOUNDRYVTT_RELEASE_TOKEN }}
#id: 'fvtt-oath-hammer' id: "fvtt-oath-hammer"
#version: ${{github.event.release.tag_name}} version: ${{github.event.release.tag_name}}
#manifest: 'https://www.uberwald.me/gitea/public/fvtt-oath-hammer/releases/download/latest/system.json' manifest: "https://www.uberwald.me/gitea/uberwald/fvtt-oath-hammer/releases/download/latest/system.json"
#notes: 'https://www.uberwald.me/gitea/${{gitea.repository}}/releases/download/${{github.event.release.tag_name}}/fvtt-oath-hammer.zip' notes: "https://www.uberwald.me/gitea/${{gitea.repository}}/releases/download/${{github.event.release.tag_name}}/fvtt-oath-hammer.zip"
#compatibility-minimum: '13' compatibility-minimum: "13"
#compatibility-verified: '14' compatibility-verified: "14"
+5
View File
@@ -2,3 +2,8 @@ _docs_private/
node_modules/ node_modules/
package-lock.json package-lock.json
.history/ .history/
.github/
# LevelDB runtime files (lock/log are regenerated on open)
packs/*/LOCK
packs/*/LOG
packs/*/LOG.old
+118
View File
@@ -565,6 +565,24 @@
opacity: 0.7; opacity: 0.7;
font-size: calc(0.86rem * 0.9); 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 .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 .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 .luck-max-group .res-bonus-input {
width: 2.2rem;
opacity: 0.85;
border-left: 1px dashed #535128;
padding-left: 2px;
}
.oathhammer .character-main .character-stats-band .character-resources .character-resource.character-resource--luck .luck-stepper { .oathhammer .character-main .character-stats-band .character-resources .character-resource.character-resource--luck .luck-stepper {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1312,6 +1330,7 @@
} }
.slots-counter { .slots-counter {
display: flex; display: flex;
align-items: center;
gap: 6px; gap: 6px;
padding: 2px 6px 4px; padding: 2px 6px 4px;
} }
@@ -1338,6 +1357,20 @@
background: rgba(192, 57, 43, 0.1); background: rgba(192, 57, 43, 0.1);
border-color: rgba(192, 57, 43, 0.4); 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-list-header,
.oathhammer .item-list--regiment .item-entry { .oathhammer .item-list--regiment .item-entry {
grid-template-columns: 24px 1fr 4rem 5rem 4rem 4.5rem; grid-template-columns: 24px 1fr 4rem 5rem 4rem 4.5rem;
@@ -1775,6 +1808,45 @@
.oathhammer .rarity-roll-btn i { .oathhammer .rarity-roll-btn i {
font-size: 0.85em; font-size: 0.85em;
} }
.oh-luck-btn-row {
margin-top: 6px;
text-align: center;
}
.oh-post-luck-btn {
background: linear-gradient(135deg, #2d6a2d 0%, #4a8c4a 100%);
color: #e8d97a;
border: 1px solid #3a7a3a;
border-radius: 4px;
padding: 4px 12px;
font-size: 0.85em;
cursor: pointer;
letter-spacing: 0.03em;
transition: filter 0.15s;
}
.oh-post-luck-btn:hover {
filter: brightness(1.15);
}
.oh-luck-result {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
padding: 4px 8px;
background: rgba(45, 106, 45, 0.15);
border-left: 3px solid #4a8c4a;
border-radius: 0 4px 4px 0;
font-size: 0.85em;
color: #2d6a2d;
}
.oh-luck-result .oh-luck-result-icon {
font-size: 1.1em;
}
.oh-luck-result .oh-luck-dice {
display: flex;
gap: 3px;
flex-wrap: wrap;
margin-left: auto;
}
.fvtt-oath-hammer .window-content { .fvtt-oath-hammer .window-content {
background: #f5ead0; background: #f5ead0;
padding: 6px 8px; padding: 6px 8px;
@@ -2196,6 +2268,16 @@
.item-list--weapon .item-entry .item-actions a[data-action="edit"] { .item-list--weapon .item-entry .item-actions a[data-action="edit"] {
margin-left: 4px; margin-left: 4px;
} }
.oh-spell-dialog .spell-header .weapon-img-sm,
.oh-miracle-dialog .spell-header .weapon-img-sm {
width: 40px;
height: 40px;
-o-object-fit: contain;
object-fit: contain;
border: 1px solid rgba(83, 81, 40, 0.2);
border-radius: 4px;
flex-shrink: 0;
}
.oh-spell-dialog .dv-badge, .oh-spell-dialog .dv-badge,
.oh-miracle-dialog .dv-badge { .oh-miracle-dialog .dv-badge {
background: #3a0e6b; background: #3a0e6b;
@@ -2296,6 +2378,42 @@
.oh-miracle-dialog select.enhancement-select { .oh-miracle-dialog select.enhancement-select {
min-width: 220px; 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-spell-card .oh-stress-line,
.oh-miracle-card .oh-stress-line { .oh-miracle-card .oh-stress-line {
margin-top: 6px; margin-top: 6px;
+13 -3
View File
@@ -230,6 +230,7 @@
"NPC": "NPC", "NPC": "NPC",
"Grit": "Grit", "Grit": "Grit",
"Luck": "Luck", "Luck": "Luck",
"LuckAvailable": "Available Luck",
"Defense": "Defense", "Defense": "Defense",
"DefenseValue": "Defense Value", "DefenseValue": "Defense Value",
"ArmorRating": "Armor Rating", "ArmorRating": "Armor Rating",
@@ -249,9 +250,10 @@
"Oaths": "Oaths", "Oaths": "Oaths",
"Weapons": "Weapons", "Weapons": "Weapons",
"Attacks": "Attacks", "Attacks": "Attacks",
"Armor": "Armor & Shields", "Armor": "Armor",
"Ammunition": "Ammunition", "Ammunition": "Ammunition",
"ItemSlots": "Item Slots", "ItemSlots": "Item Slots",
"SlotsBonus": "Bonus Slots",
"Spells": "Spells", "Spells": "Spells",
"Miracles": "Miracles", "Miracles": "Miracles",
"Equipment": "Equipment", "Equipment": "Equipment",
@@ -433,13 +435,17 @@
"LuckHint": "+2 dice each", "LuckHint": "+2 dice each",
"LuckHuman": "Human (+3d)", "LuckHuman": "Human (+3d)",
"Available": "available", "Available": "available",
"LuckPoints": "Luck Points to Spend",
"LuckPostRollTitle": "Luck Roll — {name}",
"LuckPostRollConfirm": "Roll Luck Dice",
"LuckPostRollHint": "Spend Luck Points to roll extra dice and add successes",
"Visibility": "Visibility", "Visibility": "Visibility",
"Attribute": "Attribute", "Attribute": "Attribute",
"RollSkill": "Click to roll skill check", "RollSkill": "Click to roll skill check",
"ExplodeOn5": "Explode on 5+", "ExplodeOn5": "Explode on 5+",
"ExplodeOn5Hint": "trait bonus — 5s & 6s explode", "ExplodeOn5Hint": "trait bonus — 5s & 6s explode",
"DiceColor": "Dice Color", "DiceColor": "Dice Color",
"DiceColorHint": "equipment traits can upgrade dice", "DiceColorHint": "White (4+), Red (3+), or Black (2+) thresholds",
"AttackTitle": "Attack: {weapon}", "AttackTitle": "Attack: {weapon}",
"DamageTitle": "Damage: {weapon}", "DamageTitle": "Damage: {weapon}",
"Attack": "Attack", "Attack": "Attack",
@@ -544,7 +550,11 @@
"DefenseMelee": "melee defense", "DefenseMelee": "melee defense",
"FightingNimble": "nimble weapon", "FightingNimble": "nimble weapon",
"MagicSpells": "spells" "MagicSpells": "spells"
} },
"NoLuckLeft": "No Luck Points remaining!",
"LuckRollPost": "Spend Luck",
"LuckResult": "Luck bonus:",
"NoBonus": "no bonus successes"
}, },
"Rune": { "Rune": {
"Attached": "Rune \"{name}\" attached.", "Attached": "Rune \"{name}\" attached.",
+19
View File
@@ -169,6 +169,25 @@
.res-sep { opacity: 0.7; font-size: @font-size-xs; } .res-sep { opacity: 0.7; font-size: @font-size-xs; }
.grit-max-group,
.luck-max-group {
display: flex;
align-items: center;
gap: 2px;
.res-bonus-label {
opacity: 0.6;
font-size: @font-size-xs;
}
.res-bonus-input {
width: 2.2rem;
opacity: 0.85;
border-left: 1px dashed @color-olive;
padding-left: 2px;
}
}
&.character-resource--luck { &.character-resource--luck {
.luck-stepper { .luck-stepper {
display: flex; display: flex;
+17
View File
@@ -302,6 +302,7 @@
// Slots counter on the Combat tab // Slots counter on the Combat tab
.slots-counter { .slots-counter {
display: flex; display: flex;
align-items: center;
gap: 6px; gap: 6px;
padding: 2px 6px 4px; padding: 2px 6px 4px;
@@ -330,6 +331,22 @@
border-color: fade(#c0392b, 40%); 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) ────────────────────────────────── // ── Regiment list (settlement garrison tab) ──────────────────────────────────
+49 -1
View File
@@ -450,6 +450,17 @@
.oh-miracle-dialog { .oh-miracle-dialog {
// Spell/miracle header (reuses .spell-header, .weapon-img-sm, .weapon-name-lg, .weapon-badges) // Spell/miracle header (reuses .spell-header, .weapon-img-sm, .weapon-name-lg, .weapon-badges)
.spell-header {
.weapon-img-sm {
width: 40px;
height: 40px;
object-fit: contain;
border: 1px solid @color-olive-faint;
border-radius: 4px;
flex-shrink: 0;
}
}
.dv-badge { .dv-badge {
background: #3a0e6b; background: #3a0e6b;
color: #e8d9ff; color: #e8d9ff;
@@ -542,10 +553,47 @@
i { flex-shrink: 0; } i { flex-shrink: 0; }
} }
// Wide select for enhancement list // Wide select for enhancement list (legacy, kept for miracle dialog)
select.enhancement-select { select.enhancement-select {
min-width: 220px; 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 // Chat card additions for spell/miracle
+44
View File
@@ -115,3 +115,47 @@
i { font-size: 0.85em; } i { font-size: 0.85em; }
} }
} }
// Post-roll luck button and result
.oh-luck-btn-row {
margin-top: 6px;
text-align: center;
}
.oh-post-luck-btn {
background: linear-gradient(135deg, #2d6a2d 0%, #4a8c4a 100%);
color: #e8d97a;
border: 1px solid #3a7a3a;
border-radius: 4px;
padding: 4px 12px;
font-size: 0.85em;
cursor: pointer;
letter-spacing: 0.03em;
transition: filter 0.15s;
&:hover {
filter: brightness(1.15);
}
}
.oh-luck-result {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
padding: 4px 8px;
background: fade(#2d6a2d, 15%);
border-left: 3px solid #4a8c4a;
border-radius: 0 4px 4px 0;
font-size: 0.85em;
color: #2d6a2d;
.oh-luck-result-icon { font-size: 1.1em; }
.oh-luck-dice {
display: flex;
gap: 3px;
flex-wrap: wrap;
margin-left: auto;
}
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Dialog for spending Luck Points after a roll result is known.
* Called from the "🍀 Luck" button on chat cards.
*/
export default class OathHammerLuckRollDialog {
/**
* Prompt the actor's owner to spend luck points after seeing a roll result.
* @param {Actor} actor
* @returns {Promise<{luckSpend: number, luckIsHuman: boolean}|null>}
*/
static async prompt(actor) {
const actorSys = actor.system
const availableLuck = actorSys.luck?.value ?? 0
if (availableLuck <= 0) {
ui.notifications.warn(game.i18n.localize("OATHHAMMER.Roll.NoLuckLeft"))
return null
}
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} LP (+${i * luckDicePerPoint}d)`,
selected: i === 1,
}))
const content = await foundry.applications.handlebars.renderTemplate(
"systems/fvtt-oath-hammer/templates/luck-roll-dialog.hbs",
{ availableLuck, isHuman, luckOptions }
)
const result = await foundry.applications.api.DialogV2.wait({
window: { title: game.i18n.format("OATHHAMMER.Dialog.LuckPostRollTitle", { name: actor.name }) },
classes: ["fvtt-oath-hammer"],
content,
rejectClose: false,
buttons: [{
label: game.i18n.localize("OATHHAMMER.Dialog.LuckPostRollConfirm"),
callback: (_ev, btn) => {
const spend = parseInt(btn.form.elements.luckSpend?.value) || 0
const isH = btn.form.elements.luckIsHuman?.checked ?? false
return { luckSpend: spend, luckIsHuman: isH }
},
}],
})
if (!result || result.luckSpend <= 0) return null
return result
}
}
+14 -1
View File
@@ -8,7 +8,10 @@ export default class OathHammerMiracleDialog {
const wpRank = actorSys.attributes.willpower.rank const wpRank = actorSys.attributes.willpower.rank
const magicRank = actorSys.skills.magic.rank const magicRank = actorSys.skills.magic.rank
const basePool = wpRank + magicRank const magicMod = actorSys.skills.magic.modifier ?? 0
const magicColor = actorSys.skills.magic.colorDiceType ?? "white"
const basePool = wpRank + magicRank + magicMod
const magicModDisplay = magicMod > 0 ? `+${magicMod}` : magicMod < 0 ? `${magicMod}` : ""
const isRitual = sys.isRitual const isRitual = sys.isRitual
const dv = isRitual ? (sys.difficultyValue || 1) : null const dv = isRitual ? (sys.difficultyValue || 1) : null
@@ -18,6 +21,12 @@ export default class OathHammerMiracleDialog {
return key ? game.i18n.localize(key) : sys.divineTradition return key ? game.i18n.localize(key) : sys.divineTradition
})() })()
const colorOptions = [
{ value: "white", label: game.i18n.localize("OATHHAMMER.ColorDice.White"), selected: magicColor === "white" },
{ value: "red", label: game.i18n.localize("OATHHAMMER.ColorDice.Red"), selected: magicColor === "red" },
{ value: "black", label: game.i18n.localize("OATHHAMMER.ColorDice.Black"), selected: magicColor === "black" },
]
// Miracle count options — DV = miracle number today // Miracle count options — DV = miracle number today
const miracleCountOptions = Array.from({ length: 10 }, (_, i) => ({ const miracleCountOptions = Array.from({ length: 10 }, (_, i) => ({
value: i + 1, value: i + 1,
@@ -53,8 +62,11 @@ export default class OathHammerMiracleDialog {
spellSave: sys.spellSave, spellSave: sys.spellSave,
wpRank, wpRank,
magicRank, magicRank,
magicMod,
magicModDisplay,
basePool, basePool,
miracleCountOptions, miracleCountOptions,
colorOptions,
bonusOptions, bonusOptions,
availableLuck, availableLuck,
isHuman, isHuman,
@@ -93,6 +105,7 @@ export default class OathHammerMiracleDialog {
return { return {
dv: computedDV, dv: computedDV,
isRitual, isRitual,
colorOverride: result.colorOverride ?? magicColor,
bonus: parseInt(result.bonus) || 0, bonus: parseInt(result.bonus) || 0,
visibility: result.visibility ?? game.settings.get("core", "rollMode"), visibility: result.visibility ?? game.settings.get("core", "rollMode"),
explodeOn5: result.explodeOn5 === "true", explodeOn5: result.explodeOn5 === "true",
@@ -79,7 +79,7 @@ export default class OathHammerItemSheet extends HandlebarsApplicationMixin(foun
) )
// Weapon-specific numeric selects // Weapon-specific numeric selects
context.damageModChoices = Object.fromEntries( context.damageModChoices = Object.fromEntries(
Array.from({ length: 10 }, (_, i) => [i - 4, i - 4 >= 0 ? `+${i - 4}` : String(i - 4)]) Array.from({ length: 20 }, (_, i) => [i - 4, i - 4 >= 0 ? `+${i - 4}` : String(i - 4)])
) )
context.apChoices = Object.fromEntries( context.apChoices = Object.fromEntries(
Array.from({ length: 7 }, (_, i) => [i, String(i)]) Array.from({ length: 7 }, (_, i) => [i, String(i)])
@@ -39,6 +39,7 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet {
adjustCurrency: OathHammerCharacterSheet.#onAdjustCurrency, adjustCurrency: OathHammerCharacterSheet.#onAdjustCurrency,
adjustLuck: OathHammerCharacterSheet.#onAdjustLuck, adjustLuck: OathHammerCharacterSheet.#onAdjustLuck,
adjustGrit: OathHammerCharacterSheet.#onAdjustGrit, adjustGrit: OathHammerCharacterSheet.#onAdjustGrit,
adjustStress: OathHammerCharacterSheet.#onAdjustStress,
clearStress: OathHammerCharacterSheet.#onClearStress, clearStress: OathHammerCharacterSheet.#onClearStress,
}, },
} }
@@ -190,8 +191,8 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet {
} }
}) })
context.ammunition = doc.itemTypes.ammunition context.ammunition = doc.itemTypes.ammunition
// Slot tracking: max = 10 + (Might rank × 2); used = sum of all items' slots × quantity // Slot tracking: max = 10 + (Might rank × 2) + bonus; used = sum of all items' slots × quantity
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) => { context.slotsUsed = doc.items.reduce((sum, item) => {
const qty = item.system.quantity ?? 1 const qty = item.system.quantity ?? 1
return sum + (item.system.slots ?? 0) * Math.max(qty, 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, id: m.id, uuid: m.uuid, img: m.img, name: m.name, system: m.system,
_descTooltip: _stripHtml(m.system.description) _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) => { context.slotsUsed = doc.items.reduce((sum, item) => {
const qty = item.system.quantity ?? 1 const qty = item.system.quantity ?? 1
return sum + (item.system.slots ?? 0) * Math.max(qty, 1) return sum + (item.system.slots ?? 0) * Math.max(qty, 1)
+33 -10
View File
@@ -27,7 +27,10 @@ export default class OathHammerSpellDialog {
const intRank = actorSys.attributes.intelligence.rank const intRank = actorSys.attributes.intelligence.rank
const magicRank = actorSys.skills.magic.rank const magicRank = actorSys.skills.magic.rank
const basePool = intRank + magicRank const magicMod = actorSys.skills.magic.modifier ?? 0
const magicColor = actorSys.skills.magic.colorDiceType ?? "white"
const basePool = intRank + magicRank + magicMod
const magicModDisplay = magicMod > 0 ? `+${magicMod}` : magicMod < 0 ? `${magicMod}` : ""
const currentStress = actorSys.arcaneStress.value const currentStress = actorSys.arcaneStress.value
const stressThreshold = actorSys.arcaneStress.threshold const stressThreshold = actorSys.arcaneStress.threshold
@@ -41,10 +44,17 @@ export default class OathHammerSpellDialog {
return entry ? game.i18n.localize(entry.label) : (sys.tradition ?? "") return entry ? game.i18n.localize(entry.label) : (sys.tradition ?? "")
})() })()
const enhancementOptions = Object.entries(SPELL_ENHANCEMENTS).map(([key, def]) => ({ const colorOptions = [
{ value: "white", label: game.i18n.localize("OATHHAMMER.ColorDice.White"), selected: magicColor === "white" },
{ value: "red", label: game.i18n.localize("OATHHAMMER.ColorDice.Red"), selected: magicColor === "red" },
{ value: "black", label: game.i18n.localize("OATHHAMMER.ColorDice.Black"), selected: magicColor === "black" },
]
const enhancementOptions = Object.entries(SPELL_ENHANCEMENTS)
.filter(([key]) => key !== "none")
.map(([key, def]) => ({
value: key, value: key,
label: game.i18n.localize(def.label), label: game.i18n.localize(def.label),
selected: key === "none",
})) }))
const bonusOptions = Array.from({ length: 13 }, (_, i) => { const bonusOptions = Array.from({ length: 13 }, (_, i) => {
@@ -85,8 +95,11 @@ export default class OathHammerSpellDialog {
element: sys.element, element: sys.element,
intRank, intRank,
magicRank, magicRank,
magicMod,
magicModDisplay,
basePool, basePool,
poolSizeOptions, poolSizeOptions,
colorOptions,
currentStress, currentStress,
stressThreshold, stressThreshold,
isOverThreshold, isOverThreshold,
@@ -124,16 +137,26 @@ export default class OathHammerSpellDialog {
if (!result) return null if (!result) return null
const enhKey = result.enhancement ?? "none" // Collect all checked enhancements and aggregate their effects
const enh = SPELL_ENHANCEMENTS[enhKey] ?? SPELL_ENHANCEMENTS.none 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 { return {
dv, dv,
enhancement: enhKey, enhancements: selectedEnhs.length ? selectedEnhs : ["none"],
stressCost: enh.stress, stressCost: aggregated.stress,
poolPenalty: enh.penalty, poolPenalty: aggregated.penalty,
redDice: enh.redDice, redDice: aggregated.redDice,
noStress: enh.noStress, noStress: aggregated.noStress,
colorOverride: result.colorOverride ?? magicColor,
elementalBonus: parseInt(result.elementalBonus) || 0, elementalBonus: parseInt(result.elementalBonus) || 0,
bonus: parseInt(result.bonus) || 0, bonus: parseInt(result.bonus) || 0,
poolSize: Math.min(Math.max(1, parseInt(result.poolSize) || basePool), basePool), poolSize: Math.min(Math.max(1, parseInt(result.poolSize) || basePool), basePool),
+1 -1
View File
@@ -348,7 +348,7 @@ export default class OathHammerWeaponDialog {
selected: i === defaultSV, selected: i === defaultSV,
})) }))
const damageBonusOptions = Array.from({ length: 9 }, (_, i) => { const damageBonusOptions = Array.from({ length: 20 }, (_, i) => {
const v = i - 4 const v = i - 4
return { value: v, label: v > 0 ? `+${v}` : String(v), selected: v === 0 } return { value: v, label: v > 0 ? `+${v}` : String(v), selected: v === 0 }
}) })
+13 -7
View File
@@ -16,7 +16,7 @@ export default class OathHammerCharacter extends foundry.abstract.TypeDataModel
}) })
const attributeField = () => new fields.SchemaField({ const attributeField = () => new fields.SchemaField({
rank: new fields.NumberField({ ...requiredInteger, initial: 1, min: 1, max: 4 }) rank: new fields.NumberField({ ...requiredInteger, initial: 1, min: 1 })
}) })
schema.attributes = new fields.SchemaField({ schema.attributes = new fields.SchemaField({
might: attributeField(), might: attributeField(),
@@ -68,13 +68,15 @@ export default class OathHammerCharacter extends foundry.abstract.TypeDataModel
schema.grit = new fields.SchemaField({ schema.grit = new fields.SchemaField({
value: new fields.NumberField({ ...requiredInteger, initial: 2, min: 0 }), value: new fields.NumberField({ ...requiredInteger, initial: 2, min: 0 }),
max: new fields.NumberField({ ...requiredInteger, initial: 2, min: 0 }) max: new fields.NumberField({ ...requiredInteger, initial: 2, min: 0 }),
bonus: new fields.NumberField({ required: true, nullable: false, integer: true, initial: 0 }),
}) })
// Luck.max is derived from fate.rank; resets at session start. // Luck.max is derived from fate.rank; resets at session start.
schema.luck = new fields.SchemaField({ schema.luck = new fields.SchemaField({
value: new fields.NumberField({ ...requiredInteger, initial: 1, min: 0 }), 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({ schema.arcaneStress = new fields.SchemaField({
@@ -118,6 +120,10 @@ export default class OathHammerCharacter extends foundry.abstract.TypeDataModel
copper: new fields.NumberField({ ...requiredInteger, initial: 0, min: 0 }) 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 return schema
} }
@@ -125,10 +131,10 @@ export default class OathHammerCharacter extends foundry.abstract.TypeDataModel
prepareDerivedData() { prepareDerivedData() {
super.prepareDerivedData() super.prepareDerivedData()
// Grit max = Resilience skill rank + Toughness attribute rank (rulebook p.5) // 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.max = this.skills.resilience.rank + this.attributes.toughness.rank + (this.grit.bonus ?? 0)
// Luck max = Fate rank; restores at session start // Luck max = Fate rank + bonus; restores at session start
this.luck.max = this.attributes.fate.rank this.luck.max = this.attributes.fate.rank + (this.luck.bonus ?? 0)
// Defense score = 10 + Agility + Armor Rating + bonus // Defense score = 10 + Agility + Armor Rating + bonus
this.defense.value = 10 + this.attributes.agility.rank + this.defense.armorRating + this.defense.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) // Stress Threshold = Willpower rank + Magic rank + bonus (rulebook p.101)
+1 -1
View File
@@ -15,7 +15,7 @@ export default class OathHammerWeapon extends foundry.abstract.TypeDataModel {
// Damage: melee/throwing = Might rank + damageMod dice; bows = baseDice (fixed, no Might) // Damage: melee/throwing = Might rank + damageMod dice; bows = baseDice (fixed, no Might)
// usesMight is now derived from proficiencyGroup (see getter below) // usesMight is now derived from proficiencyGroup (see getter below)
schema.damageMod = new fields.NumberField({ ...requiredInteger, initial: 0, min: -4, max: 16 }) schema.damageMod = new fields.NumberField({ ...requiredInteger, initial: 0, min: -4, max: 15 })
// AP (Armor Penetration): penalty imposed on armor/defense rolls // AP (Armor Penetration): penalty imposed on armor/defense rolls
schema.ap = new fields.NumberField({ ...requiredInteger, initial: 0, min: 0, max: 16 }) schema.ap = new fields.NumberField({ ...requiredInteger, initial: 0, min: 0, max: 16 })
+77 -12
View File
@@ -139,6 +139,7 @@ export async function rollSkillCheck(actor, skillKey, dv, options = {}) {
content, content,
rolls: allRolls, rolls: allRolls,
sound: CONFIG.sounds.dice, sound: CONFIG.sounds.dice,
flags: { "fvtt-oath-hammer": { luckRoll: _luckFlagData(actor, threshold, colorType, dv, isOpposed, explodeOn5) } },
} }
ChatMessage.applyRollMode(msgData, rollMode) ChatMessage.applyRollMode(msgData, rollMode)
await ChatMessage.create(msgData) await ChatMessage.create(msgData)
@@ -224,6 +225,59 @@ export function _diceHtml(diceResults, threshold) {
}).join(" ") }).join(" ")
} }
/**
* Build the luck flag data to store on a chat message, enabling post-roll luck spending.
* @param {Actor} actor
* @param {number} threshold Dice success threshold (2/3/4)
* @param {string} colorType "white"|"red"|"black"
* @param {number} dv Difficulty value (0 = opposed)
* @param {boolean} isOpposed
* @param {boolean} explodeOn5
*/
export function _luckFlagData(actor, threshold, colorType, dv, isOpposed, explodeOn5) {
return { actorUuid: actor.uuid, threshold, colorType, dv, isOpposed, explodeOn5 }
}
/**
* Perform a post-roll luck spend: roll extra dice and update the chat message.
* @param {ChatMessage} message
* @param {number} luckSpend
* @param {boolean} luckIsHuman
*/
export async function rollPostRollLuck(message, luckSpend, luckIsHuman) {
const flag = message.getFlag("fvtt-oath-hammer", "luckRoll")
if (!flag || flag.used || luckSpend <= 0) return
const actor = await fromUuid(flag.actorUuid)
if (!actor) return
const currentLuck = actor.system.luck?.value ?? 0
const safeSpend = Math.min(luckSpend, currentLuck)
if (safeSpend <= 0) {
ui.notifications.warn(game.i18n.localize("OATHHAMMER.Roll.NoLuckLeft"))
return
}
const luckDicePerPoint = luckIsHuman ? 3 : 2
const extraDice = safeSpend * luckDicePerPoint
const { successes, diceResults } = await _rollPool(extraDice, flag.threshold, flag.explodeOn5)
await actor.update({ "system.luck.value": Math.max(0, currentLuck - safeSpend) })
const luckDiceHtml = _diceHtml(diceResults, flag.threshold)
await message.setFlag("fvtt-oath-hammer", "luckRoll", {
...flag,
used: true,
luckSpend: safeSpend,
luckIsHuman,
bonusSuccesses: successes,
extraDiceResults: diceResults,
luckDiceHtml,
})
}
// ============================================================ // ============================================================
// WEAPON ATTACK ROLL // WEAPON ATTACK ROLL
// ============================================================ // ============================================================
@@ -309,7 +363,7 @@ export async function rollWeaponAttack(actor, weapon, options = {}) {
content, content,
rolls: rolls, rolls: rolls,
sound: CONFIG.sounds.dice, sound: CONFIG.sounds.dice,
flags: { "fvtt-oath-hammer": { weaponAttack: flagData } }, flags: { "fvtt-oath-hammer": { weaponAttack: flagData, luckRoll: _luckFlagData(actor, threshold, colorType, 0, true, explodeOn5) } },
} }
ChatMessage.applyRollMode(msgData, rollMode) ChatMessage.applyRollMode(msgData, rollMode)
await ChatMessage.create(msgData) await ChatMessage.create(msgData)
@@ -409,11 +463,12 @@ export async function rollWeaponDamage(actor, weapon, options = {}) {
export async function rollSpellCast(actor, spell, options = {}) { export async function rollSpellCast(actor, spell, options = {}) {
const { const {
dv = spell.system.difficultyValue, dv = spell.system.difficultyValue,
enhancement = "none", enhancements = ["none"],
stressCost = 0, stressCost = 0,
poolPenalty = 0, poolPenalty = 0,
redDice = false, redDice = false,
noStress = false, noStress = false,
colorOverride = "white",
elementalBonus = 0, elementalBonus = 0,
bonus = 0, bonus = 0,
poolSize = null, poolSize = null,
@@ -429,14 +484,15 @@ export async function rollSpellCast(actor, spell, options = {}) {
const intRank = actorSys.attributes.intelligence.rank const intRank = actorSys.attributes.intelligence.rank
const magicRank = actorSys.skills.magic.rank const magicRank = actorSys.skills.magic.rank
const magicMod = actorSys.skills.magic.modifier ?? 0
const luckDicePerPoint = luckIsHuman ? 3 : 2 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] // poolSize: voluntary reduction (p.101) — clamped to [1, baseDice]
const totalDice = poolSize !== null const totalDice = poolSize !== null
? Math.max(1, Math.min(poolSize + bonus + poolPenalty + elementalBonus + grimPenalty + (luckSpend * luckDicePerPoint), baseDice)) ? Math.max(1, Math.min(poolSize + bonus + poolPenalty + elementalBonus + grimPenalty + (luckSpend * luckDicePerPoint), baseDice))
: Math.max(baseDice, 1) : Math.max(baseDice, 1)
const threshold = redDice ? 3 : 4 const threshold = colorOverride === "black" ? 2 : colorOverride === "red" ? 3 : 4
const colorEmoji = redDice ? "🔴" : "⬜" const colorEmoji = colorOverride === "black" ? "⬛" : colorOverride === "red" ? "🔴" : "⬜"
const { roll, rolls, successes, diceResults } = await _rollPool(totalDice, threshold, explodeOn5) const { roll, rolls, successes, diceResults } = await _rollPool(totalDice, threshold, explodeOn5)
const diceHtml = _diceHtml(diceResults, threshold) const diceHtml = _diceHtml(diceResults, threshold)
@@ -468,9 +524,11 @@ export async function rollSpellCast(actor, spell, options = {}) {
: game.i18n.localize("OATHHAMMER.Roll.Failure") : game.i18n.localize("OATHHAMMER.Roll.Failure")
const modParts = [] 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 (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 (elementalBonus > 0) modParts.push(`+${elementalBonus} ${game.i18n.localize("OATHHAMMER.Dialog.ElementMet")}`)
if (grimPenalty < 0) modParts.push(`${grimPenalty} ${game.i18n.localize("OATHHAMMER.Dialog.GrimoireNo")}`) 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 ? " 👤" : ""})`) if (luckSpend > 0) modParts.push(`+${luckSpend * luckDicePerPoint} ${game.i18n.localize("OATHHAMMER.Dialog.LuckSpend")} (${luckSpend}LP${luckIsHuman ? " 👤" : ""})`)
@@ -492,7 +550,7 @@ export async function rollSpellCast(actor, spell, options = {}) {
<span>${spell.name} (DV ${dv})</span> <span>${spell.name} (DV ${dv})</span>
</div> </div>
<div class="oh-roll-info"> <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> <span>${colorEmoji} ${totalDice}d6 (${threshold}+)</span>
</div> </div>
${modLine} ${modLine}
@@ -511,6 +569,7 @@ export async function rollSpellCast(actor, spell, options = {}) {
content, content,
rolls: rolls, rolls: rolls,
sound: CONFIG.sounds.dice, sound: CONFIG.sounds.dice,
flags: { "fvtt-oath-hammer": { luckRoll: _luckFlagData(actor, threshold, colorOverride, dv, false, explodeOn5) } },
} }
ChatMessage.applyRollMode(msgData, rollMode) ChatMessage.applyRollMode(msgData, rollMode)
await ChatMessage.create(msgData) await ChatMessage.create(msgData)
@@ -534,6 +593,7 @@ export async function rollMiracleCast(actor, miracle, options = {}) {
const { const {
dv = 1, dv = 1,
isRitual = false, isRitual = false,
colorOverride = "white",
bonus = 0, bonus = 0,
visibility, visibility,
explodeOn5 = false, explodeOn5 = false,
@@ -546,10 +606,11 @@ export async function rollMiracleCast(actor, miracle, options = {}) {
const wpRank = actorSys.attributes.willpower.rank const wpRank = actorSys.attributes.willpower.rank
const magicRank = actorSys.skills.magic.rank const magicRank = actorSys.skills.magic.rank
const magicMod = actorSys.skills.magic.modifier ?? 0
const luckDicePerPoint = luckIsHuman ? 3 : 2 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 = 4 const threshold = colorOverride === "black" ? 2 : colorOverride === "red" ? 3 : 4
const colorEmoji = "⬜" const colorEmoji = colorOverride === "black" ? "⬛" : colorOverride === "red" ? "🔴" : "⬜"
const { roll, rolls, successes, diceResults } = await _rollPool(totalDice, threshold, explodeOn5) const { roll, rolls, successes, diceResults } = await _rollPool(totalDice, threshold, explodeOn5)
const diceHtml = _diceHtml(diceResults, threshold) const diceHtml = _diceHtml(diceResults, threshold)
@@ -589,7 +650,7 @@ export async function rollMiracleCast(actor, miracle, options = {}) {
<span>${miracle.name} (${dvNote})</span> <span>${miracle.name} (${dvNote})</span>
</div> </div>
<div class="oh-roll-info"> <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> <span>${colorEmoji} ${totalDice}d6 (${threshold}+)</span>
</div> </div>
${modLine} ${modLine}
@@ -608,6 +669,7 @@ export async function rollMiracleCast(actor, miracle, options = {}) {
content, content,
rolls: rolls, rolls: rolls,
sound: CONFIG.sounds.dice, sound: CONFIG.sounds.dice,
flags: { "fvtt-oath-hammer": { luckRoll: _luckFlagData(actor, threshold, colorOverride, dv, false, explodeOn5) } },
} }
ChatMessage.applyRollMode(msgData, rollMode) ChatMessage.applyRollMode(msgData, rollMode)
await ChatMessage.create(msgData) await ChatMessage.create(msgData)
@@ -683,11 +745,13 @@ export async function rollDefense(actor, options = {}) {
` `
const rollMode = visibility ?? game.settings.get("core", "rollMode") const rollMode = visibility ?? game.settings.get("core", "rollMode")
const defColorType = redDice ? "red" : "white"
const msgData = { const msgData = {
speaker: ChatMessage.getSpeaker({ actor }), speaker: ChatMessage.getSpeaker({ actor }),
content, content,
rolls: rolls, rolls: rolls,
sound: CONFIG.sounds.dice, sound: CONFIG.sounds.dice,
flags: { "fvtt-oath-hammer": { luckRoll: _luckFlagData(actor, threshold, defColorType, 0, true, false) } },
} }
ChatMessage.applyRollMode(msgData, rollMode) ChatMessage.applyRollMode(msgData, rollMode)
await ChatMessage.create(msgData) await ChatMessage.create(msgData)
@@ -786,6 +850,7 @@ export async function rollWeaponDefense(actor, weapon, options = {}) {
content, content,
rolls: rolls, rolls: rolls,
sound: CONFIG.sounds.dice, sound: CONFIG.sounds.dice,
flags: { "fvtt-oath-hammer": { luckRoll: _luckFlagData(actor, threshold, colorOverride, 0, true, explodeOn5) } },
} }
ChatMessage.applyRollMode(msgData, rollMode) ChatMessage.applyRollMode(msgData, rollMode)
await ChatMessage.create(msgData) await ChatMessage.create(msgData)
+82 -2
View File
@@ -8,7 +8,9 @@ import OathHammerUtils from "./module/utils.mjs"
import OathHammerWeaponDialog from "./module/applications/weapon-dialog.mjs" import OathHammerWeaponDialog from "./module/applications/weapon-dialog.mjs"
import OathHammerCombat from "./module/combat.mjs" import OathHammerCombat from "./module/combat.mjs"
import { rollWeaponDamage } from "./module/rolls.mjs" import { rollWeaponDamage } from "./module/rolls.mjs"
import { rollPostRollLuck } from "./module/rolls.mjs"
import { injectFreeRollBar } from "./module/applications/free-roll.mjs" import { injectFreeRollBar } from "./module/applications/free-roll.mjs"
import OathHammerLuckRollDialog from "./module/applications/luck-roll-dialog.mjs"
Hooks.once("init", function () { Hooks.once("init", function () {
console.info(SYSTEM.ASCII) console.info(SYSTEM.ASCII)
@@ -139,6 +141,9 @@ Hooks.once("ready", async function () {
await item.delete() await item.delete()
} }
} }
// Auto-import the welcome scene if GM and not already present
if (game.user.isGM) await _importWelcomeScene()
}) })
// Auto-link regiment (and army) actor tokens so they can be added to garrisons/armies // Auto-link regiment (and army) actor tokens so they can be added to garrisons/armies
@@ -149,9 +154,10 @@ Hooks.on("preCreateActor", (actor, _data, _options, _userId) => {
}) })
// Handle "Roll Damage" button in weapon attack chat cards // Handle "Roll Damage" button in weapon attack chat cards
Hooks.on("renderChatMessageHTML", (message, html) => { Hooks.on("renderChatMessageHTML", async (message, html) => {
// Weapon damage button
const btn = html.querySelector("[data-action=\"rollWeaponDamage\"]") const btn = html.querySelector("[data-action=\"rollWeaponDamage\"]")
if (!btn) return if (btn) {
btn.addEventListener("click", async () => { btn.addEventListener("click", async () => {
const flagData = message.getFlag("fvtt-oath-hammer", "weaponAttack") const flagData = message.getFlag("fvtt-oath-hammer", "weaponAttack")
if (!flagData) return if (!flagData) return
@@ -162,8 +168,82 @@ Hooks.on("renderChatMessageHTML", (message, html) => {
const opts = await OathHammerWeaponDialog.promptDamage(actor, weapon, attackSuccesses ?? 0) const opts = await OathHammerWeaponDialog.promptDamage(actor, weapon, attackSuccesses ?? 0)
if (opts) await rollWeaponDamage(actor, weapon, opts) if (opts) await rollWeaponDamage(actor, weapon, opts)
}) })
}
// Luck post-roll button
const luckFlag = message.getFlag("fvtt-oath-hammer", "luckRoll")
if (!luckFlag) return
const resultDiv = html.querySelector(".oh-roll-result")
if (!resultDiv) return
if (luckFlag.used) {
// Show luck result section
const bonusLabel = luckFlag.bonusSuccesses > 0
? `+${luckFlag.bonusSuccesses} ${game.i18n.localize("OATHHAMMER.Roll.Successes")}`
: game.i18n.localize("OATHHAMMER.Roll.NoBonus")
const resultHtml = `
<div class="oh-luck-result">
<span class="oh-luck-result-icon">🍀</span>
<span>${game.i18n.localize("OATHHAMMER.Roll.LuckResult")} ${bonusLabel}</span>
<span class="oh-luck-dice">${luckFlag.luckDiceHtml ?? ""}</span>
</div>`
resultDiv.insertAdjacentHTML("afterend", resultHtml)
} else {
// Show "Spend Luck" button if actor owns the message and has luck left
const actor = await fromUuid(luckFlag.actorUuid).catch(() => null)
if (!actor?.isOwner) return
const availableLuck = actor.system.luck?.value ?? 0
if (availableLuck <= 0) return
const btnHtml = `
<div class="oh-luck-btn-row">
<button type="button" class="oh-post-luck-btn" data-action="postRollLuck">
🍀 ${game.i18n.localize("OATHHAMMER.Roll.LuckRollPost")}
</button>
</div>`
resultDiv.insertAdjacentHTML("afterend", btnHtml)
html.querySelector("[data-action=\"postRollLuck\"]")?.addEventListener("click", async () => {
const actor = await fromUuid(luckFlag.actorUuid).catch(() => null)
if (!actor) return
const opts = await OathHammerLuckRollDialog.prompt(actor)
if (!opts) return
await rollPostRollLuck(message, opts.luckSpend, opts.luckIsHuman)
})
}
}) })
// Inject Free Roll bar into the chat sidebar // Inject Free Roll bar into the chat sidebar
Hooks.on("renderChatLog", (_chatLog, html) => injectFreeRollBar(_chatLog, html)) Hooks.on("renderChatLog", (_chatLog, html) => injectFreeRollBar(_chatLog, html))
// ============================================================
// WELCOME SCENE — auto-create on first world load (GM only)
// ============================================================
const WELCOME_SCENE_NAME = "Oath Hammer"
const WELCOME_SCENE_MAP = "systems/fvtt-oath-hammer/assets/images/oathhammer_map.webp"
/** Scene data for the welcome map (3600×5400 px, no grid — world map). */
function _welcomeSceneData() {
return {
name: WELCOME_SCENE_NAME,
background: { src: WELCOME_SCENE_MAP },
width: 3600,
height: 5400,
grid: { type: 0, size: 100 }, // gridless
padding: 0,
initial: { x: 1800, y: 2700, scale: 0.25 },
tokenVision: false,
flags: { "fvtt-oath-hammer": { welcomeScene: true } },
}
}
async function _importWelcomeScene() {
// Skip if the scene already exists in the world
if (game.scenes.find(s => s.name === WELCOME_SCENE_NAME)) return
console.info("Oath Hammer | Creating welcome scene…")
const scene = await Scene.create(_welcomeSceneData())
await scene.activate()
console.info("Oath Hammer | Welcome scene created and activated.")
}
+7 -3
View File
@@ -8,14 +8,18 @@
"watch": "gulp watch" "watch": "gulp watch"
}, },
"devDependencies": { "devDependencies": {
"autoprefixer": "^10.4.20",
"classic-level": "^1.4.1",
"gulp": "^4.0.2", "gulp": "^4.0.2",
"gulp-less": "^5.0.0", "gulp-less": "^5.0.0",
"less": "^4.2.0",
"autoprefixer": "^10.4.20",
"gulp-postcss": "^9.0.1", "gulp-postcss": "^9.0.1",
"less": "^4.2.0",
"postcss": "^8.4.49" "postcss": "^8.4.49"
}, },
"keywords": ["foundry-vtt", "oath-hammer"], "keywords": [
"foundry-vtt",
"oath-hammer"
],
"author": "", "author": "",
"license": "ISC" "license": "ISC"
} }
View File
+1
View File
@@ -0,0 +1 @@
MANIFEST-000030
Binary file not shown.
View File
View File
Binary file not shown.
+23 -3
View File
@@ -2,7 +2,13 @@
"id": "fvtt-oath-hammer", "id": "fvtt-oath-hammer",
"title": "Oath Hammer RPG", "title": "Oath Hammer RPG",
"description": "Oath Hammer RPG System for FoundryVTT", "description": "Oath Hammer RPG System for FoundryVTT",
"version": "14.0.0", "version": "14.0.1",
"authors": [
{
"name": "Uberwald",
"url": "https://www.uberwald.me/gitea/uberwald/fvtt-oath-hammer"
}
],
"compatibility": { "compatibility": {
"minimum": "13", "minimum": "13",
"verified": "14" "verified": "14"
@@ -150,6 +156,20 @@
} }
}, },
"url": "https://www.uberwald.me/gitea/uberwald/fvtt-oath-hammer", "url": "https://www.uberwald.me/gitea/uberwald/fvtt-oath-hammer",
"manifest": "https://www.uberwald.me/gitea/public/fvtt-oath-hammer/releases/download/latest/system.json", "manifest": "https://www.uberwald.me/gitea/uberwald/fvtt-oath-hammer/releases/download/latest/system.json",
"download": "https://www.uberwald.me/gitea/public/fvtt-oath-hammer/releases/download/latest/fvtt-oath-hammer.zip" "download": "https://www.uberwald.me/gitea/uberwald/fvtt-oath-hammer/releases/download/14.0.1/fvtt-oath-hammer.zip",
"packs": [
{
"label": "Scenes",
"type": "Scene",
"name": "scenes",
"path": "packs/scenes",
"system": "fvtt-oath-hammer",
"flags": {},
"ownership": {
"PLAYER": "OBSERVER",
"ASSISTANT": "OWNER"
}
}
]
} }
+2
View File
@@ -2,6 +2,8 @@
<div class="slots-counter"> <div class="slots-counter">
<span class="slots-label">{{localize "OATHHAMMER.Label.ItemSlots"}}</span> <span class="slots-label">{{localize "OATHHAMMER.Label.ItemSlots"}}</span>
<span class="slots-value {{#if slotsOver}}slots-over{{/if}}">{{slotsUsed}} / {{slotsMax}}</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> </div>
<fieldset class="currency-bar"> <fieldset class="currency-bar">
<legend>{{localize "OATHHAMMER.Label.Currency"}}</legend> <legend>{{localize "OATHHAMMER.Label.Currency"}}</legend>
+14 -2
View File
@@ -56,7 +56,13 @@
<a data-action="adjustGrit" data-delta="1" class="grit-btn">+</a> <a data-action="adjustGrit" data-delta="1" class="grit-btn">+</a>
</div> </div>
<span class="res-sep">/</span> <span class="res-sep">/</span>
{{formInput systemFields.grit.fields.max value=system.grit.max name="system.grit.max" disabled=isPlayMode}} <span class="grit-max-group">
{{formInput systemFields.grit.fields.max value=system.grit.max name="system.grit.max" disabled=true}}
{{#unless isPlayMode}}
<span class="res-bonus-label">+</span>
{{formInput systemFields.grit.fields.bonus value=system.grit.bonus name="system.grit.bonus" class="res-bonus-input"}}
{{/unless}}
</span>
</div> </div>
<div class="character-resource character-resource--luck"> <div class="character-resource character-resource--luck">
<span class="resource-label">{{localize "OATHHAMMER.Label.Luck"}}</span> <span class="resource-label">{{localize "OATHHAMMER.Label.Luck"}}</span>
@@ -66,7 +72,13 @@
<a data-action="adjustLuck" data-delta="1" class="luck-btn">+</a> <a data-action="adjustLuck" data-delta="1" class="luck-btn">+</a>
</div> </div>
<span class="res-sep">/</span> <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>
<div class="character-resource"> <div class="character-resource">
<span class="resource-label">{{localize "OATHHAMMER.Label.Movement"}}</span> <span class="resource-label">{{localize "OATHHAMMER.Label.Movement"}}</span>
+30
View File
@@ -0,0 +1,30 @@
<div class="oh-roll-dialog oh-luck-dialog">
<div class="luck-dialog-info">
<i class="fa-solid fa-clover luck-icon"></i>
<span>{{localize "OATHHAMMER.Dialog.LuckPostRollHint"}}</span>
</div>
<fieldset class="attack-options-block">
<legend>{{localize "OATHHAMMER.Dialog.LuckSpend"}}</legend>
<div class="pool-info-line">
{{localize "OATHHAMMER.Label.LuckAvailable"}}: <strong>{{availableLuck}}</strong>
</div>
<div class="roll-option-row">
<label>{{localize "OATHHAMMER.Dialog.LuckPoints"}}</label>
<select name="luckSpend">
{{#each luckOptions}}<option value="{{value}}"{{#if selected}} selected{{/if}}>{{label}}</option>{{/each}}
</select>
</div>
<div class="roll-option-row roll-option-check">
<label for="postLuckIsHuman">{{localize "OATHHAMMER.Dialog.LuckHuman"}} <i class="fa-solid fa-clover luck-icon"></i></label>
<input type="checkbox" id="postLuckIsHuman" name="luckIsHuman" value="true" {{#if isHuman}}checked{{/if}} />
<span class="roll-option-hint">{{localize "OATHHAMMER.Dialog.LuckHint"}}</span>
</div>
</fieldset>
</div>
+9
View File
@@ -32,6 +32,7 @@
<div class="pool-info-line"> <div class="pool-info-line">
{{localize "OATHHAMMER.Skill.Magic"}} ({{localize "OATHHAMMER.Attribute.Willpower"}} {{wpRank}}) {{localize "OATHHAMMER.Skill.Magic"}} ({{localize "OATHHAMMER.Attribute.Willpower"}} {{wpRank}})
+ {{localize "OATHHAMMER.Label.SkillRank"}} {{magicRank}} + {{localize "OATHHAMMER.Label.SkillRank"}} {{magicRank}}
{{#if magicModDisplay}} {{localize "OATHHAMMER.Label.SkillModifier"}} {{magicModDisplay}}{{/if}}
= <strong>{{basePool}}d6</strong> = <strong>{{basePool}}d6</strong>
</div> </div>
@@ -45,6 +46,14 @@
</div> </div>
{{/unless}} {{/unless}}
<div class="roll-option-row">
<label>{{localize "OATHHAMMER.Dialog.DiceColor"}}</label>
<select name="colorOverride">
{{#each colorOptions}}<option value="{{value}}"{{#if selected}} selected{{/if}}>{{label}}</option>{{/each}}
</select>
<span class="roll-option-hint">{{localize "OATHHAMMER.Dialog.DiceColorHint"}}</span>
</div>
<div class="roll-option-row"> <div class="roll-option-row">
<label>{{localize "OATHHAMMER.Dialog.Modifier"}}</label> <label>{{localize "OATHHAMMER.Dialog.Modifier"}}</label>
<select name="bonus"> <select name="bonus">
+17 -3
View File
@@ -31,6 +31,7 @@
<div class="pool-info-line"> <div class="pool-info-line">
{{localize "OATHHAMMER.Skill.Magic"}} ({{localize "OATHHAMMER.Attribute.Intelligence"}} {{intRank}}) {{localize "OATHHAMMER.Skill.Magic"}} ({{localize "OATHHAMMER.Attribute.Intelligence"}} {{intRank}})
+ {{localize "OATHHAMMER.Label.SkillRank"}} {{magicRank}} + {{localize "OATHHAMMER.Label.SkillRank"}} {{magicRank}}
{{#if magicModDisplay}} {{localize "OATHHAMMER.Label.SkillModifier"}} {{magicModDisplay}}{{/if}}
= <strong>{{basePool}}d6</strong> = <strong>{{basePool}}d6</strong>
</div> </div>
@@ -45,10 +46,23 @@
{{/if}} {{/if}}
<div class="roll-option-row"> <div class="roll-option-row">
<label>{{localize "OATHHAMMER.Dialog.Enhancement"}}</label> <label>{{localize "OATHHAMMER.Dialog.DiceColor"}}</label>
<select name="enhancement" class="enhancement-select"> <select name="colorOverride">
{{#each enhancementOptions}}<option value="{{value}}"{{#if selected}} selected{{/if}}>{{label}}</option>{{/each}} {{#each colorOptions}}<option value="{{value}}"{{#if selected}} selected{{/if}}>{{label}}</option>{{/each}}
</select> </select>
<span class="roll-option-hint">{{localize "OATHHAMMER.Dialog.DiceColorHint"}}</span>
</div>
<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> </div>
{{#if isElemental}} {{#if isElemental}}