diff --git a/CHANGELOG.md b/CHANGELOG.md
index 11589cc..02ad9f1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,11 @@ All notable changes to the **HbM RPG v3** Foundry VTT system.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [Unreleased]
+
+### Added
+- Spell cast dialog has a **Modyfikator liczby kości** (dice modifier, +/-). It changes the number of dice in the casting test for every casting mode (standard, sacred, witch, blood, prayer); the pool never drops below 0. The over-limit Nadczarowanie test uses the same modified pool. API: `CastOptions.diceModifier`, `applyDiceModifier()` in `rules.ts`.
+
## [1.5.1] - 2026-10-22
### Added
diff --git a/lang/en.json b/lang/en.json
index c74db31..38d9585 100644
--- a/lang/en.json
+++ b/lang/en.json
@@ -363,6 +363,8 @@
},
"spellCast": {
"title": "Casting spell",
+ "diceModifier": "Dice modifier",
+ "diceModifierHint": "+/- dice in the casting test (the pool never drops below 0)",
"manaSpent": "Mana spent",
"cannotBeUnraveled": "Cannot be unraveled",
"zealSpent": "Zeal spent",
diff --git a/lang/pl.json b/lang/pl.json
index b222e9e..2b61d96 100644
--- a/lang/pl.json
+++ b/lang/pl.json
@@ -383,6 +383,8 @@
},
"spellCast": {
"title": "Rzucanie zaklęcia",
+ "diceModifier": "Modyfikator liczby kości",
+ "diceModifierHint": "+/- kości w Teście rzucania (liczba kości nie spadnie poniżej 0)",
"manaSpent": "Wydana mana",
"cannotBeUnraveled": "Nie da się rozpleść",
"cast": "Rzuć",
diff --git a/src/dice/cast-dialog.ts b/src/dice/cast-dialog.ts
index 2ecdab4..29cd61a 100644
--- a/src/dice/cast-dialog.ts
+++ b/src/dice/cast-dialog.ts
@@ -76,6 +76,12 @@ export async function askCastOptions(spell: SpellForDialog, actor: ActorForDialo
`);
}
+ fields.push(`
+
+ ${game.i18n.localize('HBM.spellCast.diceModifier')}
+
+ ${game.i18n.localize('HBM.spellCast.diceModifierHint')}
+
`);
if (mode !== 'sacred') {
fields.push(`
@@ -162,6 +168,8 @@ export async function askCastOptions(spell: SpellForDialog, actor: ActorForDialo
}
if (fd.zealSpent != null) opts.zealSpent = Math.max(1, Number(fd.zealSpent) || 1);
if (fd.bloodSpent != null) opts.bloodSpent = Math.max(baseBlood, Number(fd.bloodSpent) || baseBlood);
+ const diceMod = Math.trunc(Number(fd.diceModifier) || 0);
+ if (diceMod !== 0) opts.diceModifier = diceMod;
if (fd.hekateMode) opts.hekateMode = 'witch';
if (fd.castAsPrayer) opts.castAsPrayer = true;
if (fd.bypassSuperspellWarning) opts.bypassSuperspellWarning = true;
diff --git a/src/logic/rules.ts b/src/logic/rules.ts
index 879a3a6..6e1c247 100644
--- a/src/logic/rules.ts
+++ b/src/logic/rules.ts
@@ -259,3 +259,8 @@ export function negotiationBand(successes: number): number {
export function fearThresholdMod(sourceId: string | undefined, targetId: string | undefined): number {
return sourceId && targetId && sourceId === targetId ? 1 : 0;
}
+
+/** Casting test pool with the player's +/- dice modifier (whole dice, never below 0). */
+export function applyDiceModifier(pool: number, modifier?: number): number {
+ return Math.max(0, Math.trunc(Number(pool) || 0) + Math.trunc(Number(modifier) || 0));
+}
diff --git a/src/logic/spell-cast.ts b/src/logic/spell-cast.ts
index 3c38ec3..300e6e9 100644
--- a/src/logic/spell-cast.ts
+++ b/src/logic/spell-cast.ts
@@ -16,7 +16,7 @@
import { HbmTSRoll } from '../dice/ts-roll';
import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD, getMagicPowerEntry } from '../constants';
-import { manaToBorrow, exhaustionDice, manaExhaustionTier, overLimitTest, SPELL_DAMAGE_PROFILES } from './rules';
+import { manaToBorrow, exhaustionDice, manaExhaustionTier, overLimitTest, applyDiceModifier, SPELL_DAMAGE_PROFILES } from './rules';
import { validateCast } from './spell-validation';
import type { ValidationResult } from './spell-validation';
import { rollSpellDamage } from './spell-damage';
@@ -76,6 +76,8 @@ export interface CastOptions {
bypassSuperspellWarning?: boolean;
bypassNonCombatBlock?: boolean;
groupCasters?: string[];
+ /** Modifier to the number of dice in the casting test (+/-, pool never below 0). */
+ diceModifier?: number;
/** Override required successes (used by variableSuccesses summons). */
requiredOverride?: number;
speaker?: ChatMessage.SpeakerData;
@@ -276,9 +278,10 @@ async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOp
const magicDice = typeof a.magic.dicePool === 'number'
? a.magic.dicePool
: getMagicPowerEntry(a.magic.actual ?? a.magic.value ?? 0).dicePool;
- const pool = opts.castAsPrayer
+ const basePool = opts.castAsPrayer
? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0);
+ const pool = applyDiceModifier(basePool, opts.diceModifier);
const flavor = opts.castAsPrayer
? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')}`
: `${spell.name}`;
@@ -349,7 +352,7 @@ async function castSacred(actor: CastableActor, spell: SpellLike, opts: CastOpti
const zealCost = Math.max(1, opts.zealSpent ?? 1);
await actor.update({ 'system.attributes.zeal.value': a.zeal.value - zealCost });
- const pool = a.soul.value + (actor.system.skills?.devotion?.value ?? 0);
+ const pool = applyDiceModifier(a.soul.value + (actor.system.skills?.devotion?.value ?? 0), opts.diceModifier);
const flavor = `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.sacred')}`;
const required = opts.requiredOverride ?? spell.system.difficulty.successes ?? TS_DEFAULT_REQUIRED;
const roll = HbmTSRoll.fromParams({
@@ -379,9 +382,10 @@ async function castWitch(actor: CastableActor, spell: SpellLike, opts: CastOptio
const magicDice = typeof (a.magic as any).dicePool === 'number'
? (a.magic as any).dicePool
: getMagicPowerEntry((a.magic as any).actual ?? a.magic.value ?? 0).dicePool;
- const pool = opts.castAsPrayer
+ const basePool = opts.castAsPrayer
? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0);
+ const pool = applyDiceModifier(basePool, opts.diceModifier);
const flavor = opts.castAsPrayer
? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')} (${required}/${maxSymbols})`
: `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.witch')} (${required}/${maxSymbols})`;
@@ -421,9 +425,10 @@ async function castBlood(actor: CastableActor, spell: SpellLike, opts: CastOptio
const magicDice = typeof (a.magic as any).dicePool === 'number'
? (a.magic as any).dicePool
: getMagicPowerEntry((a.magic as any).actual ?? a.magic.value ?? 0).dicePool;
- const pool = opts.castAsPrayer
+ const basePool = opts.castAsPrayer
? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0);
+ const pool = applyDiceModifier(basePool, opts.diceModifier);
const flavor = opts.castAsPrayer
? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')}`
: `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.blood')}`;
diff --git a/tests/logic/rules.test.ts b/tests/logic/rules.test.ts
index f393319..4eec255 100644
--- a/tests/logic/rules.test.ts
+++ b/tests/logic/rules.test.ts
@@ -104,3 +104,14 @@ describe('blood, elixirs, trade', () => {
expect([0, 1, 2, 3, 4, 5, 6, 7, 12].map(negotiationBand)).toEqual([0, 1, 1, 2, 2, 3, 3, 4, 4]);
});
});
+
+describe('applyDiceModifier', () => {
+ it('adds a signed modifier and never goes below 0', async () => {
+ const { applyDiceModifier } = await import('../../src/logic/rules');
+ expect(applyDiceModifier(5, 2)).toBe(7);
+ expect(applyDiceModifier(5, -2)).toBe(3);
+ expect(applyDiceModifier(2, -5)).toBe(0);
+ expect(applyDiceModifier(4)).toBe(4);
+ expect(applyDiceModifier(4, 1.9)).toBe(5);
+ });
+});