Author SHA1 Message Date
octoturgeandClaude Sonnet 5.5 27c900e26d feat: dice modifier in the spell cast dialog
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E48f1kXhsEbAUxdkNP1yDM
2026-10-26 21:31:20 +01:00
12 changed files with 47 additions and 38 deletions
+3 -4
View File
@@ -5,11 +5,10 @@ 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 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). this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.5.2] - 2026-10-26 ## [Unreleased]
### Fixed ### Added
- A character with the discipline Magia Sakralna - Egzorcyzmy (`sacredExorcism`) is now treated as a Sacred Magic caster in the spell discipline gate. Before, it only matched its own id, so exorcists failed the requirement for Sacred spells. The match is one-way: a plain Sacred mage is not an exorcist. - 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`.
- The "new round" chat message and README no longer claim that Mana is reset. Mana is a daily pool in the vault and has not been reset per round since 1.3.x; the message now says which counters really reset.
## [1.5.1] - 2026-10-22 ## [1.5.1] - 2026-10-22
+1 -1
View File
@@ -16,7 +16,7 @@ A companion lore module ([`foundry-lore`](https://github.com/iwo-strzebonski/hbm
- **TS d6 dice pool roller** (`HbmTSRoll`) - `1` auto-fail, `6` auto-success, configurable Threshold (T) and required Successes (Y), plus T-step / Y-step modifiers driven by Active Effects. - **TS d6 dice pool roller** (`HbmTSRoll`) - `1` auto-fail, `6` auto-success, configurable Threshold (T) and required Successes (Y), plus T-step / Y-step modifiers driven by Active Effects.
- **Derived stats** - `health.max = 3 × (body + mind + soul)`, `zeal.max = ceil(soul/2)`, initiative pool, mana table lookup (Magic Power Level 0–X), blood pool (`body + soul`), elixir tolerance cap (`body + 1`), insanity counter. - **Derived stats** - `health.max = 3 × (body + mind + soul)`, `zeal.max = ceil(soul/2)`, initiative pool, mana table lookup (Magic Power Level 0–X), blood pool (`body + soul`), elixir tolerance cap (`body + 1`), insanity counter.
- **4-layer damage model** - Magical Armor (with runic counter) → Magical Shield → Physical Armor (DR + condition) → Health. `Apply Damage` dialog with bypass flags; auto-applies `Oszołomiony` on heavy HP loss. - **4-layer damage model** - Magical Armor (with runic counter) → Magical Shield → Physical Armor (DR + condition) → Health. `Apply Damage` dialog with bypass flags; auto-applies `Oszołomiony` on heavy HP loss.
- **Combat hooks** - Mana is a daily pool (not reset per round; recovered by rests); Zeal +1 each turn; Immediate Action and Krew counters reset each round; turns auto-skipped for Nieprzytomny / Obezwładniony; Podpalony ticks 1 environmental damage; Umierający prompts a Death Save. - **Combat hooks** - Mana resets each round; Zeal +1 each turn; turns auto-skipped for Nieprzytomny / Obezwładniony; Podpalony ticks 1 environmental damage; Umierający prompts a Death Save.
- **Spell-cast workflow** - `validateCast()` gates on resources / race / talent / discipline / deity / group / inCombat / superspell / witch-symbol; dispatcher routes to standard / Sacred / Witch / Blood / Hekate hybrid; `rollSpellDamage()` parses formula tokens; reactive triggers (`killWithWeapon`, `targetCastsSpell`, `damageTaken`, `turnStart`). - **Spell-cast workflow** - `validateCast()` gates on resources / race / talent / discipline / deity / group / inCombat / superspell / witch-symbol; dispatcher routes to standard / Sacred / Witch / Blood / Hekate hybrid; `rollSpellDamage()` parses formula tokens; reactive triggers (`killWithWeapon`, `targetCastsSpell`, `damageTaken`, `turnStart`).
- **Cast dialog** - mana / zeal / blood inputs, group-caster picker, Hekate toggle, GM bypass checkboxes (superspell, non-combat block), inline validation feedback. - **Cast dialog** - mana / zeal / blood inputs, group-caster picker, Hekate toggle, GM bypass checkboxes (superspell, non-combat block), inline validation feedback.
- **Mechanics modules** (`game.hbm.*`) - `bloodMagic` (Self-Harm, Life Steal, talent integration), `abyssMagic` (Aspects vs Primal, Mistrz Losu penalty, `Dary Otchłani`, insanity gain), `brewing` (TS test + tolerance counter), `rest` (short / long), `trade` (founding company workflow, transactions, smuggling). - **Mechanics modules** (`game.hbm.*`) - `bloodMagic` (Self-Harm, Life Steal, talent integration), `abyssMagic` (Aspects vs Primal, Mistrz Losu penalty, `Dary Otchłani`, insanity gain), `brewing` (TS test + tolerance counter), `rest` (short / long), `trade` (founding company workflow, transactions, smuggling).
+3 -1
View File
@@ -363,6 +363,8 @@
}, },
"spellCast": { "spellCast": {
"title": "Casting spell", "title": "Casting spell",
"diceModifier": "Dice modifier",
"diceModifierHint": "+/- dice in the casting test (the pool never drops below 0)",
"manaSpent": "Mana spent", "manaSpent": "Mana spent",
"cannotBeUnraveled": "Cannot be unraveled", "cannotBeUnraveled": "Cannot be unraveled",
"zealSpent": "Zeal spent", "zealSpent": "Zeal spent",
@@ -423,7 +425,7 @@
} }
}, },
"combat": { "combat": {
"newRound": "New round - Immediate Action and Krew counters reset", "newRound": "New round - Mana reset",
"newTurn": "New turn - Zeal +1", "newTurn": "New turn - Zeal +1",
"turnSkipped": "Turn skipped (condition)", "turnSkipped": "Turn skipped (condition)",
"deathSavePrompt": "Make a Death Saving Throw (Body, T:4, Y:1)" "deathSavePrompt": "Make a Death Saving Throw (Body, T:4, Y:1)"
+3 -1
View File
@@ -383,6 +383,8 @@
}, },
"spellCast": { "spellCast": {
"title": "Rzucanie zaklęcia", "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", "manaSpent": "Wydana mana",
"cannotBeUnraveled": "Nie da się rozpleść", "cannotBeUnraveled": "Nie da się rozpleść",
"cast": "Rzuć", "cast": "Rzuć",
@@ -426,7 +428,7 @@
} }
}, },
"combat": { "combat": {
"newRound": "Nowa runda - liczniki Akcji Natychmiastowych i Krwi zresetowane", "newRound": "Nowa runda - Mana zresetowana",
"newTurn": "Nowa tura - Zapał +1", "newTurn": "Nowa tura - Zapał +1",
"turnSkipped": "Tura pominięta (stan)", "turnSkipped": "Tura pominięta (stan)",
"deathSavePrompt": "Wykonaj Rzut Obronny przeciw Śmierci (Ciało, T:4, Y:1)" "deathSavePrompt": "Wykonaj Rzut Obronny przeciw Śmierci (Ciało, T:4, Y:1)"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hbm-rpg-v3-foundry", "name": "hbm-rpg-v3-foundry",
"version": "1.5.2", "version": "1.5.1",
"description": "Foundry VTT system for Homebrew Magic: Role Playing Game v3", "description": "Foundry VTT system for Homebrew Magic: Role Playing Game v3",
"private": true, "private": true,
"type": "module", "type": "module",
+8
View File
@@ -76,6 +76,12 @@ export async function askCastOptions(spell: SpellForDialog, actor: ActorForDialo
<input type="number" name="bloodSpent" value="${baseBlood}" min="${baseBlood}" max="${max}"/> <input type="number" name="bloodSpent" value="${baseBlood}" min="${baseBlood}" max="${max}"/>
</div>`); </div>`);
} }
fields.push(`
<div class="form-group">
<label>${game.i18n.localize('HBM.spellCast.diceModifier')}</label>
<input type="number" name="diceModifier" value="0" step="1"/>
<small>${game.i18n.localize('HBM.spellCast.diceModifierHint')}</small>
</div>`);
if (mode !== 'sacred') { if (mode !== 'sacred') {
fields.push(` fields.push(`
<div class="form-group"> <div class="form-group">
@@ -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.zealSpent != null) opts.zealSpent = Math.max(1, Number(fd.zealSpent) || 1);
if (fd.bloodSpent != null) opts.bloodSpent = Math.max(baseBlood, Number(fd.bloodSpent) || baseBlood); 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.hekateMode) opts.hekateMode = 'witch';
if (fd.castAsPrayer) opts.castAsPrayer = true; if (fd.castAsPrayer) opts.castAsPrayer = true;
if (fd.bypassSuperspellWarning) opts.bypassSuperspellWarning = true; if (fd.bypassSuperspellWarning) opts.bypassSuperspellWarning = true;
+5
View File
@@ -259,3 +259,8 @@ export function negotiationBand(successes: number): number {
export function fearThresholdMod(sourceId: string | undefined, targetId: string | undefined): number { export function fearThresholdMod(sourceId: string | undefined, targetId: string | undefined): number {
return sourceId && targetId && sourceId === targetId ? 1 : 0; 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));
}
+10 -5
View File
@@ -16,7 +16,7 @@
import { HbmTSRoll } from '../dice/ts-roll'; import { HbmTSRoll } from '../dice/ts-roll';
import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD, getMagicPowerEntry } from '../constants'; 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 { validateCast } from './spell-validation';
import type { ValidationResult } from './spell-validation'; import type { ValidationResult } from './spell-validation';
import { rollSpellDamage } from './spell-damage'; import { rollSpellDamage } from './spell-damage';
@@ -76,6 +76,8 @@ export interface CastOptions {
bypassSuperspellWarning?: boolean; bypassSuperspellWarning?: boolean;
bypassNonCombatBlock?: boolean; bypassNonCombatBlock?: boolean;
groupCasters?: string[]; 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). */ /** Override required successes (used by variableSuccesses summons). */
requiredOverride?: number; requiredOverride?: number;
speaker?: ChatMessage.SpeakerData; speaker?: ChatMessage.SpeakerData;
@@ -276,9 +278,10 @@ async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOp
const magicDice = typeof a.magic.dicePool === 'number' const magicDice = typeof a.magic.dicePool === 'number'
? a.magic.dicePool ? a.magic.dicePool
: getMagicPowerEntry(a.magic.actual ?? a.magic.value ?? 0).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) ? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0); : magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0);
const pool = applyDiceModifier(basePool, opts.diceModifier);
const flavor = opts.castAsPrayer const flavor = opts.castAsPrayer
? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')}` ? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')}`
: `${spell.name}`; : `${spell.name}`;
@@ -349,7 +352,7 @@ async function castSacred(actor: CastableActor, spell: SpellLike, opts: CastOpti
const zealCost = Math.max(1, opts.zealSpent ?? 1); const zealCost = Math.max(1, opts.zealSpent ?? 1);
await actor.update({ 'system.attributes.zeal.value': a.zeal.value - zealCost }); 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 flavor = `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.sacred')}`;
const required = opts.requiredOverride ?? spell.system.difficulty.successes ?? TS_DEFAULT_REQUIRED; const required = opts.requiredOverride ?? spell.system.difficulty.successes ?? TS_DEFAULT_REQUIRED;
const roll = HbmTSRoll.fromParams({ 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' const magicDice = typeof (a.magic as any).dicePool === 'number'
? (a.magic as any).dicePool ? (a.magic as any).dicePool
: getMagicPowerEntry((a.magic as any).actual ?? a.magic.value ?? 0).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) ? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0); : magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0);
const pool = applyDiceModifier(basePool, opts.diceModifier);
const flavor = opts.castAsPrayer const flavor = opts.castAsPrayer
? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')} (${required}/${maxSymbols})` ? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')} (${required}/${maxSymbols})`
: `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.witch')} (${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' const magicDice = typeof (a.magic as any).dicePool === 'number'
? (a.magic as any).dicePool ? (a.magic as any).dicePool
: getMagicPowerEntry((a.magic as any).actual ?? a.magic.value ?? 0).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) ? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0); : magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0);
const pool = applyDiceModifier(basePool, opts.diceModifier);
const flavor = opts.castAsPrayer const flavor = opts.castAsPrayer
? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')}` ? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')}`
: `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.blood')}`; : `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.blood')}`;
+1 -5
View File
@@ -41,7 +41,7 @@ function actorHasTalent(actor: CastableActor, talentId: string): boolean {
return false; return false;
} }
export function matchDiscipline(disciplineId: string, targetId: string): boolean { function matchDiscipline(disciplineId: string, targetId: string): boolean {
const d1 = disciplineId.toLowerCase().trim(); const d1 = disciplineId.toLowerCase().trim();
const d2 = targetId.toLowerCase().trim(); const d2 = targetId.toLowerCase().trim();
if (d1 === d2) return true; if (d1 === d2) return true;
@@ -52,8 +52,6 @@ export function matchDiscipline(disciplineId: string, targetId: string): boolean
abyssprimal: ['abyssprimal', 'magia otchłani', 'primal', 'eldritch', 'eldritch magic', 'abyss - primal', 'magia otchłani - pierwotna magia', 'pierwotna magia'], abyssprimal: ['abyssprimal', 'magia otchłani', 'primal', 'eldritch', 'eldritch magic', 'abyss - primal', 'magia otchłani - pierwotna magia', 'pierwotna magia'],
witch: ['witch', 'wiedźmia magia', 'witch magic', 'dzika wiedźmia magia', 'wildwitch'], witch: ['witch', 'wiedźmia magia', 'witch magic', 'dzika wiedźmia magia', 'wildwitch'],
blood: ['blood', 'magia krwi', 'blood magic'], blood: ['blood', 'magia krwi', 'blood magic'],
sacred: ['sacred', 'magia sakralna', 'sacred magic'],
sacredexorcism: ['sacredexorcism', 'magia sakralna - egzorcyzmy', 'egzorcyzmy', 'exorcism', 'exorcisms', 'sacred - exorcisms'],
}; };
let key1: string | null = null; let key1: string | null = null;
@@ -65,8 +63,6 @@ export function matchDiscipline(disciplineId: string, targetId: string): boolean
} }
if (key1 && key2 && key1 === key2) return true; if (key1 && key2 && key1 === key2) return true;
// Egzorcyzmy are a sub-discipline of Magia Sakralna: an exorcist counts as Sacred, not the other way round.
if (key1 === 'sacredexorcism' && key2 === 'sacred') return true;
return false; return false;
} }
+1 -1
View File
@@ -2,7 +2,7 @@
"id": "hbm-rpg-v3", "id": "hbm-rpg-v3",
"title": "Homebrew Magic: RPG v3", "title": "Homebrew Magic: RPG v3",
"description": "System Foundry VTT dla Homebrew Magic: Role Playing Game v3 - autorska gra fabularna z mechaniką puli kości d6 (TS - Trudność:Sukcesy).", "description": "System Foundry VTT dla Homebrew Magic: Role Playing Game v3 - autorska gra fabularna z mechaniką puli kości d6 (TS - Trudność:Sukcesy).",
"version": "1.5.2", "version": "1.5.1",
"manifest": "https://vtt-content.octoturge.com/packages/system/hbm-rpg-v3.json", "manifest": "https://vtt-content.octoturge.com/packages/system/hbm-rpg-v3.json",
"download": "https://vtt-content.octoturge.com/packages/system/hbm-rpg-v3-v1.5.0.zip", "download": "https://vtt-content.octoturge.com/packages/system/hbm-rpg-v3-v1.5.0.zip",
"compatibility": { "compatibility": {
+11
View File
@@ -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]); 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);
});
});
-19
View File
@@ -1,19 +0,0 @@
import { describe, it, expect } from 'vitest';
import { matchDiscipline } from '../../src/logic/spell-validation';
describe('matchDiscipline: Magia Sakralna - Egzorcyzmy', () => {
it('an exorcist counts as Sacred Magic for casting', () => {
expect(matchDiscipline('sacredExorcism', 'sacred')).toBe(true);
expect(matchDiscipline('Magia Sakralna - Egzorcyzmy', 'sacred')).toBe(true);
expect(matchDiscipline('Egzorcyzmy', 'Magia Sakralna')).toBe(true);
});
it('a plain Sacred mage is not an exorcist', () => {
expect(matchDiscipline('sacred', 'sacredExorcism')).toBe(false);
expect(matchDiscipline('Magia Sakralna', 'egzorcyzmy')).toBe(false);
});
it('does not leak into other disciplines', () => {
expect(matchDiscipline('sacredExorcism', 'witch')).toBe(false);
});
});