From 27c900e26df0e0873032df5c3a6faf9defd8c9d0 Mon Sep 17 00:00:00 2001 From: Octoturge Date: Mon, 26 Oct 2026 21:31:20 +0100 Subject: [PATCH] feat: dice modifier in the spell cast dialog Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01E48f1kXhsEbAUxdkNP1yDM --- CHANGELOG.md | 5 +++++ lang/en.json | 2 ++ lang/pl.json | 2 ++ src/dice/cast-dialog.ts | 8 ++++++++ src/logic/rules.ts | 5 +++++ src/logic/spell-cast.ts | 15 ++++++++++----- tests/logic/rules.test.ts | 11 +++++++++++ 7 files changed, 43 insertions(+), 5 deletions(-) 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.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); + }); +});