wip: 2026-10-26 sync - casting penalties, prayer route, Hartowany, madness threshold (untested)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E48f1kXhsEbAUxdkNP1yDM
This commit is contained in:
2026-10-29 17:19:45 +01:00
co-authored by Claude Sonnet 5.5
parent c5e53e591a
commit 23c1e94ab5
18 changed files with 247 additions and 96 deletions
+26 -12
View File
@@ -12,12 +12,12 @@
* when present; otherwise falls back to a chat
* prompt for the GM).
* - rollMistrzLosuPenalty(actor) - d100 penalty roll (Klątwa Otchłani VII).
* - addInsanity(actor, n) - increments actor.system.attributes.insanity;
* when crossing a threshold, fires a hook for
* the GM to apply a mental condition.
* - addInsanity(actor, n) - adds Punkty Obłędu; at Próg Obłędu resets them,
* rolls the illness k100 and fires `hbm.mentalIllness`.
*/
import type { CastableActor } from './spell-cast';
import { madnessThreshold } from './rules';
export type AbyssDiscipline = 'aspects' | 'primal';
@@ -40,19 +40,33 @@ export function dispatchAbyssCast(spell: SpellLikeForAbyss): AbyssDiscipline {
return 'aspects';
}
/** Insanity thresholds (Klątwa Otchłani VIII). Crossing any triggers a mental-condition roll. */
const INSANITY_THRESHOLDS = [3, 6, 10, 15] as const;
/**
* Punkty Obłędu (Choroby Psychiczne). On reaching Próg Obłędu (Dusza + 3, Krasnolud +1) the points reset
* to 0 (the excess is lost) and the character gains one illness: k100 against the 50-illness table in the
* vault (`rules/02. Klątwa Otchłani/Rozdział IV - Choroby Psychiczne.md`), re-rolling duplicates. The table
* itself is not copied here, so the roll is posted to chat for the GM and `hbm.mentalIllness` is fired.
*/
export async function addInsanity(actor: CastableActor, n: number): Promise<{ before: number; after: number; thresholdsCrossed: number[] }> {
if (n <= 0) return { before: 0, after: 0, thresholdsCrossed: [] };
const before = ((actor.system.attributes as any).insanity ?? 0) as number;
const attrs = actor.system.attributes as any;
const before = (attrs.insanity ?? 0) as number;
const after = before + n;
await actor.update({ 'system.attributes.insanity': after });
const crossed = INSANITY_THRESHOLDS.filter((t) => before < t && after >= t);
if (crossed.length > 0) {
Hooks.callAll('hbm.insanityThreshold', actor, crossed, { before, after });
const raceItem = (actor as any).items?.find?.((i: any) => i.type === 'race');
const raceId = String(raceItem?.system?.raceId ?? raceItem?.name ?? (actor as any).system?.details?.raceId ?? '');
const threshold = madnessThreshold(Number(attrs.soul?.value ?? 0), raceId);
if (after < threshold) {
await actor.update({ 'system.attributes.insanity': after });
return { before, after, thresholdsCrossed: [] };
}
return { before, after, thresholdsCrossed: [...crossed] };
await actor.update({ 'system.attributes.insanity': 0 });
const roll = new Roll('1d100');
await roll.evaluate();
await roll.toMessage({
flavor: `${game.i18n.localize('HBM.insanity.illnessRoll')} - ${actor.name}`,
speaker: ChatMessage.getSpeaker({ actor: actor as unknown as Actor }),
});
Hooks.callAll('hbm.mentalIllness', actor, { before, after, threshold, roll: roll.total });
return { before, after: 0, thresholdsCrossed: [threshold] };
}
/** Roll d100 against a named table inside the abyss roll-tables pack. */
+1
View File
@@ -85,6 +85,7 @@ async function onApplyDamage(btn: HTMLElement): Promise<void> {
shieldMultiplier: profile?.shieldMultiplier,
destroyMagicalDefenses: profile?.destroyMagicalDefenses,
minDamageAfterArmor: btn.dataset.minAfterArmor === 'true',
fire: btn.dataset.fire === 'true',
} as any);
}
+5 -2
View File
@@ -14,7 +14,7 @@
*/
import { DamageType } from '../constants';
import { triggersStun, concentrationTest, runicArmorPointsDisabled } from './rules';
import { triggersStun, concentrationTest, runicArmorPointsDisabled, hardenedFireDamage, actorHasTalent } from './rules';
import { gainKrewFromWeaponDamage } from './blood-magic';
export interface ApplyDamageOptions {
@@ -39,6 +39,8 @@ export interface ApplyDamageOptions {
woundKinds?: ('slashing' | 'piercing' | 'blunt')[];
/** Attacker, for passive Krew gain (Szacunek do Życia / Krwisty Pojedynkowicz). */
sourceActor?: any;
/** Fire damage: a Hartowany w Ogniu target takes 2 less per hit (min 0). */
fire?: boolean;
postChat?: boolean;
}
@@ -68,7 +70,8 @@ interface ActorLike {
}
export async function applyDamage(actor: ActorLike, opts: ApplyDamageOptions): Promise<DamageReport> {
const total = Math.max(0, Math.floor(opts.amount));
let total = Math.max(0, Math.floor(opts.amount));
if (opts.fire && actorHasTalent(actor as any, 'Hartowany w Ogniu')) total = hardenedFireDamage(total);
const a = actor.system.attributes;
const updates: Record<string, unknown> = {};
const report: DamageReport = {
+104 -2
View File
@@ -4,8 +4,8 @@
*
* Rounding convention: everything fractional rounds UP, except
* - new-spell mana cost (nearest, min 1),
* - Nadczarowanie bonus from level 3 (halved, DOWN, min 1),
* - fire damage with Hartowany w Ogniu (halved, DOWN).
* - Nadczarowanie bonus from level 3 (halved, DOWN, min 1).
* (Hartowany w Ogniu is a flat -2 per fire hit since the 2026-10-26 sync, no rounding.)
*/
/** Odetchnięcie: at most twice between two Długie Odpoczynki. */
@@ -133,6 +133,108 @@ export function bloodToZeal(blood: number): number {
return Math.floor(Math.max(0, blood) / 5);
}
// ---------------------------------------------------------------------------
// 2026-10-26 sync
// ---------------------------------------------------------------------------
export interface WornArmorPiece {
/** `armor.armorType` of an equipped gear item. */
armorType: string;
/** Special-case item that never hampers casting (Średnia zbroja kultysty, Napierśnik Ninurty). */
ignoresCastingPenalty?: boolean;
}
export interface CastingArmorPenalty {
/** Dice removed from the Magia test (positive number). */
dice: number;
/** Extra Mana per spell, also when overcasting. */
mana: number;
}
/**
* Zbroja a rzucanie zaklęć: Średni -1 die; Ciężki -3 dice and +2 Mana; Tarcza -1 die (stacks).
* Pancerz Wiary removes the armour penalties (dice AND Mana) but never the shield penalty.
* Runiczny Pancerz and Magiczne Osłony are not gear and never carry a penalty.
*/
export function castingArmorPenalty(worn: WornArmorPiece[], faithArmor = false): CastingArmorPenalty {
let dice = 0;
let mana = 0;
for (const piece of worn) {
if (piece.ignoresCastingPenalty) continue;
if (piece.armorType === 'shield') dice += 1;
else if (faithArmor) continue;
else if (piece.armorType === 'medium') dice += 1;
else if (piece.armorType === 'heavy') { dice += 3; mana += 2; }
}
return { dice, mana };
}
/** Does the actor own a talent item with this id / name? (case-insensitive) */
export function actorHasTalent(actor: unknown, talentId: string): boolean {
const id = talentId.toLowerCase();
const items = (actor as any)?.items as Iterable<any> | undefined;
if (!items) return false;
for (const it of items) {
if (it.type !== 'talent') continue;
const t = String(it.system?.talentId ?? it.system?.id ?? it.name ?? '').toLowerCase();
if (t === id) return true;
}
return false;
}
/** Prayer or regular spell? Sacred spells default to a prayer; everything else defaults to a regular spell. */
export function resolveCastAsPrayer(
spell: { system: { castingMode?: string } },
opts: { castAsPrayer?: boolean; hekateMode?: string },
): boolean {
if (opts.castAsPrayer != null) return opts.castAsPrayer;
return (spell.system.castingMode ?? 'standard') === 'sacred' && opts.hekateMode !== 'witch';
}
/** Hartowany w Ogniu: each fire hit deals 2 less (min 0). */
export function hardenedFireDamage(amount: number): number {
return Math.max(0, Math.trunc(amount) - 2);
}
/** Próg Obłędu: Dusza + 3 for everyone, Krasnolud +1. */
export function madnessThreshold(soul: number, raceId = ''): number {
return Math.max(0, Math.trunc(soul)) + 3 + (raceId.toLowerCase() === 'dwarf' || raceId.toLowerCase() === 'krasnolud' ? 1 : 0);
}
/** Golem limit: floor(Umysł / 2), min 1 (spell golems count too; two straw golems count as one). */
export function golemLimit(mind: number): number {
return Math.max(1, Math.floor(Math.max(0, mind) / 2));
}
/** Upadek: 1 per full metre above 2 m, cap 20; Ciało (Atletyka) 4:1 halves it (rounded down). */
export function fallDamage(metres: number, halved = false): number {
const raw = Math.min(20, Math.max(0, Math.floor(metres) - 2));
return halved ? Math.floor(raw / 2) : raw;
}
/** A fall of 4 m or more knocks the target Przewrócony unless the landing was cushioned. */
export const fallKnocksProne = (metres: number, cushioned = false) => !cushioned && metres >= 4;
/** Test Przeżycia (Umierający): tier by damage past the death threshold; each hit while dying raises S's T by 1. */
export function survivalTest(damagePastThreshold: number, hitsWhileDying = 0): { threshold: number; successes: number } {
const successes = damagePastThreshold <= 2 ? 1 : damagePastThreshold <= 5 ? 2 : 3;
return { threshold: 4 + Math.max(0, hitsWhileDying), successes };
}
/** Pełny Unik: Unik tests are one Stopień easier until the start of the next turn. */
export const FULL_DODGE_THRESHOLD_DELTA = -1;
/**
* Zapał regen at the start of a turn is blocked when since the start of the previous turn the character
* took a PAID extra Action or a PAID extra Immediate Action (beyond the free one). Zapał spent on prayers
* or Talents with their own Zapał cost does not count; Eliksir Szybkości's extra Action is free.
*/
export function zealRegenBlocked(paidExtraActions: number, paidExtraImmediateActions: number): boolean {
return paidExtraActions > 0 || paidExtraImmediateActions > 0;
}
/** Spells of category Obrażenia: +1 damage once, after multipliers/division; not for periodic/overcast/curses/artifacts. */
export const DAMAGE_CATEGORY_BONUS = 1;
/**
* Runiczny Pancerz – points disabled by ONE hit (hits are never added together).
* Default: a hit of more than 5 damage (after the Runiczny Pancerz step) disables 1 point.
+49 -33
View File
@@ -16,7 +16,8 @@
import { HbmTSRoll } from '../dice/ts-roll';
import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD, getMagicPowerEntry } from '../constants';
import { manaToBorrow, exhaustionDice, manaExhaustionTier, overLimitTest, applyDiceModifier, SPELL_DAMAGE_PROFILES } from './rules';
import { manaToBorrow, exhaustionDice, manaExhaustionTier, overLimitTest, applyDiceModifier, castingArmorPenalty, actorHasTalent, resolveCastAsPrayer, SPELL_DAMAGE_PROFILES } from './rules';
import type { CastingArmorPenalty } from './rules';
import { validateCast } from './spell-validation';
import type { ValidationResult } from './spell-validation';
import { rollSpellDamage } from './spell-damage';
@@ -49,12 +50,13 @@ export interface SpellLike {
deity?: string;
manaCost: number;
bloodCost?: number;
complexityLevel: number;
isSuperspell?: boolean;
requiresGroupCast?: boolean;
minCasters?: number;
nonCombatOnly?: boolean;
damageBase?: string;
/** Spell category is Obrażenia (+1 damage). Not carried by vault frontmatter yet; set per spell. */
damageCategory?: boolean;
damageType?: string;
ignoresArmor?: boolean;
statusEffects?: string[];
@@ -72,6 +74,11 @@ export interface CastOptions {
zealSpent?: number;
bloodSpent?: number;
hekateMode?: 'sacred' | 'witch';
/**
* Cast as a prayer (Dusza + Oddanie, Mana + 1 Zapał, cannot be dispelled) instead of a regular spell
* (Magia + Zdolności Magiczne, Mana only, can be dispelled). Every known spell may be cast either way;
* for `castingMode: sacred` spells an unset value means prayer (the usual route for a priest).
*/
castAsPrayer?: boolean;
bypassSuperspellWarning?: boolean;
bypassNonCombatBlock?: boolean;
@@ -111,7 +118,22 @@ async function recordRoll(rolls: CastRollEntry[], roll: HbmTSRoll, label: string
}
}
export async function castSpell(actor: CastableActor, spell: SpellLike, opts: CastOptions = {}): Promise<CastResult> {
/** Equipped armour / shield worn by the actor, as the casting-penalty helper wants it. */
export function castingPenaltyFor(actor: CastableActor): CastingArmorPenalty {
const items = (actor as any).items as Iterable<any> | undefined;
const worn: Array<{ armorType: string; ignoresCastingPenalty?: boolean }> = [];
for (const it of items ?? []) {
if (it.type !== 'gear' || !it.system?.equipped || it.system?.category !== 'armor') continue;
worn.push({
armorType: String(it.system.armor?.armorType ?? 'light'),
ignoresCastingPenalty: !!it.system.armor?.ignoresCastingPenalty,
});
}
return castingArmorPenalty(worn, actorHasTalent(actor, 'Pancerz Wiary'));
}
export async function castSpell(actor: CastableActor, spell: SpellLike, rawOpts: CastOptions = {}): Promise<CastResult> {
const opts: CastOptions = { ...rawOpts, castAsPrayer: resolveCastAsPrayer(spell, rawOpts) };
// 1. Validate
const validation = validateCast(actor, spell, opts);
if (!validation.ok) {
@@ -129,9 +151,10 @@ export async function castSpell(actor: CastableActor, spell: SpellLike, opts: Ca
let baseRoll: HbmTSRoll | null = null;
const rolls: CastRollEntry[] = [];
if (mode === 'sacred') {
// Sacred spells are cast like any other spell: as a prayer (default) or as a regular spell.
baseRoll = opts.hekateMode === 'witch'
? await castWitch(actor, spell, opts, rolls)
: await castSacred(actor, spell, opts, rolls);
: await castStandard(actor, spell, opts, rolls);
} else if (mode === 'witch') {
baseRoll = await castWitch(actor, spell, opts, rolls);
} else if (mode === 'blood') {
@@ -146,7 +169,11 @@ export async function castSpell(actor: CastableActor, spell: SpellLike, opts: Ca
if (baseRoll?.ts?.isSuccess) {
// 4. Damage
if (spell.system.damageBase) {
damageRoll = await rollSpellDamage(spell.system.damageBase, { actor, spellName: spell.name });
damageRoll = await rollSpellDamage(spell.system.damageBase, {
actor,
spellName: spell.name,
categoryBonus: !!spell.system.damageCategory,
});
}
// 6. Triggers
@@ -200,6 +227,8 @@ async function renderCastCard(
})),
type: spell.system.damageType ?? 'magical',
ignoresArmor: !!spell.system.ignoresArmor,
// Fire damage: Hartowany w Ogniu takes 2 off each hit.
fire: spell.system.school === 'elementsFire',
// Magiczny Pocisk: at least 1 damage reaches Żywotność after Pancerz.
minAfterArmor: ((spell as any).flags?.['hbm-rpg-v3']?.slug ?? (spell.system as any).slug) === 'magic-missile',
// Shield-breakers / Szkarłatny Pocisk / Prawdziwa Dezintegracja treat the magical defences specially.
@@ -260,7 +289,9 @@ async function renderCastCard(
async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOptions, rolls: CastRollEntry[]): Promise<HbmTSRoll | null> {
const baseCost = Math.max(0, spell.system.manaCost ?? 0);
const manaSpent = Math.max(baseCost, Math.floor(opts.manaSpent ?? baseCost));
const armorPenalty = castingPenaltyFor(actor);
// Ciężki armour: +2 Mana per spell, overcast included.
const manaSpent = Math.max(baseCost, Math.floor(opts.manaSpent ?? baseCost)) + armorPenalty.mana;
const a = actor.system.attributes;
// Casting beyond the remaining daily pool is allowed: the missing mana is
@@ -278,9 +309,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;
// Armour and shield penalties hit the Magia test only (a prayer rolls Dusza + Oddanie).
const basePool = opts.castAsPrayer
? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0);
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0) - armorPenalty.dice;
const pool = applyDiceModifier(basePool, opts.diceModifier);
const flavor = opts.castAsPrayer
? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')}`
@@ -347,32 +379,15 @@ async function rollIncompetencePenalty(actor: CastableActor, missing: number, ba
});
}
async function castSacred(actor: CastableActor, spell: SpellLike, opts: CastOptions, rolls: CastRollEntry[]): Promise<HbmTSRoll | null> {
const a = actor.system.attributes;
const zealCost = Math.max(1, opts.zealSpent ?? 1);
await actor.update({ 'system.attributes.zeal.value': a.zeal.value - zealCost });
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({
pool,
threshold: spell.system.difficulty.threshold ?? TS_DEFAULT_THRESHOLD,
required,
flavor,
});
await roll.evaluate();
await recordRoll(rolls, roll, flavor);
return roll;
}
async function castWitch(actor: CastableActor, spell: SpellLike, opts: CastOptions, rolls: CastRollEntry[]): Promise<HbmTSRoll | null> {
const a = actor.system.attributes;
const required = Math.max(0, spell.system.complexityLevel ?? spell.system.components?.symbols?.length ?? 0);
// Poziom Skomplikowania = number of Symbols in the spell.
const required = Math.max(0, spell.system.components?.symbols?.length ?? 0);
const maxSymbols = Math.ceil(a.magic.actual / 2);
const armorPenalty = castingPenaltyFor(actor);
// Witch magic still has a mana cost.
const baseCost = Math.max(0, spell.system.manaCost ?? 0);
// Witch magic still has a mana cost (+2 in Ciężki armour).
const baseCost = Math.max(0, spell.system.manaCost ?? 0) + armorPenalty.mana;
const updates: Record<string, any> = { 'system.attributes.mana.value': a.mana.value - baseCost };
if (opts.castAsPrayer) {
updates['system.attributes.zeal.value'] = a.zeal.value - 1;
@@ -384,7 +399,7 @@ async function castWitch(actor: CastableActor, spell: SpellLike, opts: CastOptio
: getMagicPowerEntry((a.magic as any).actual ?? a.magic.value ?? 0).dicePool;
const basePool = opts.castAsPrayer
? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0);
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0) - armorPenalty.dice;
const pool = applyDiceModifier(basePool, opts.diceModifier);
const flavor = opts.castAsPrayer
? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')} (${required}/${maxSymbols})`
@@ -412,8 +427,9 @@ async function castBlood(actor: CastableActor, spell: SpellLike, opts: CastOptio
updates['system.attributes.blood.value'] = Math.max(0, blood.value - bloodSpent);
}
// Mana cost (some blood spells may also have one)
const manaCost = Math.max(0, spell.system.manaCost ?? 0);
// Mana cost (some blood spells may also have one; +2 in Ciężki armour)
const armorPenalty = castingPenaltyFor(actor);
const manaCost = Math.max(0, spell.system.manaCost ?? 0) + (Math.max(0, spell.system.manaCost ?? 0) > 0 ? armorPenalty.mana : 0);
if (manaCost > 0) {
updates['system.attributes.mana.value'] = a.mana.value - manaCost;
}
@@ -427,7 +443,7 @@ async function castBlood(actor: CastableActor, spell: SpellLike, opts: CastOptio
: getMagicPowerEntry((a.magic as any).actual ?? a.magic.value ?? 0).dicePool;
const basePool = opts.castAsPrayer
? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0);
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0) - armorPenalty.dice;
const pool = applyDiceModifier(basePool, opts.diceModifier);
const flavor = opts.castAsPrayer
? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')}`
+7 -1
View File
@@ -13,10 +13,16 @@
*/
import type { CastableActor } from './spell-cast';
import { DAMAGE_CATEGORY_BONUS } from './rules';
export interface DamageContext {
actor: CastableActor;
spellName?: string;
/**
* Spell is of category Obrażenia: +1 damage, added once after multipliers/division
* (not for periodic damage, overcast bonuses, other categories, curses or artifacts).
*/
categoryBonus?: boolean;
}
const ATTR_TOKENS = new Set(['body', 'mind', 'soul', 'magic', 'magicalAbilities', 'devotion']);
@@ -61,7 +67,7 @@ export function buildDamageFormula(spellDamageBase: string, ctx: DamageContext):
return String(v);
});
return formula;
return ctx.categoryBonus ? `(${formula}) + ${DAMAGE_CATEGORY_BONUS}` : formula;
}
export async function rollSpellDamage(spellDamageBase: string, ctx: DamageContext): Promise<Roll | null> {
+10 -23
View File
@@ -5,6 +5,7 @@
*/
import type { CastableActor, SpellLike, CastOptions } from './spell-cast';
import { resolveCastAsPrayer, actorHasTalent } from './rules';
export interface ValidationIssue {
code: string;
@@ -29,18 +30,6 @@ function getActorRaceId(actor: CastableActor): string {
return String(d.raceId ?? d.race ?? '').toLowerCase();
}
function actorHasTalent(actor: CastableActor, talentId: string): boolean {
const id = talentId.toLowerCase();
const items = (actor as any).items as Iterable<any> | undefined;
if (!items) return false;
for (const it of items) {
if (it.type !== 'talent') continue;
const t = String(it.system?.talentId ?? it.system?.id ?? it.name ?? '').toLowerCase();
if (t === id) return true;
}
return false;
}
export function matchDiscipline(disciplineId: string, targetId: string): boolean {
const d1 = disciplineId.toLowerCase().trim();
const d2 = targetId.toLowerCase().trim();
@@ -52,6 +41,8 @@ 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'],
witch: ['witch', 'wiedźmia magia', 'witch magic', 'dzika wiedźmia magia', 'wildwitch'],
blood: ['blood', 'magia krwi', 'blood magic'],
// Vault spells say `runic`; the discipline item id is `runes`.
runes: ['runes', 'runic', 'magia runiczna', 'runic magic'],
sacred: ['sacred', 'magia sakralna', 'sacred magic'],
sacredexorcism: ['sacredexorcism', 'magia sakralna - egzorcyzmy', 'egzorcyzmy', 'exorcism', 'exorcisms', 'sacred - exorcisms'],
};
@@ -102,21 +93,16 @@ export function validateCast(actor: CastableActor, spell: SpellLike, opts: CastO
const manaSpent = Math.max(baseMana, Math.floor(opts.manaSpent ?? baseMana));
const bloodSpent = Math.max(0, Math.floor(opts.bloodSpent ?? Number(sys.bloodCost ?? 0)));
if (mode === 'standard' || mode === 'witch' || (mode === 'sacred' && opts.hekateMode === 'witch')) {
if (mode === 'standard' || mode === 'witch' || mode === 'sacred') {
if (manaSpent > Number(a.mana?.value ?? 0)) {
// Allowed since the 2026-10-20 sync: the shortfall is borrowed (Wyczerpanie Many).
warnings.push({ code: 'borrow-mana', message: 'Not enough mana - the shortfall will be borrowed (Wyczerpanie Many)', i18nKey: 'HBM.spellCast.borrowMana' });
}
}
if (opts.castAsPrayer) {
if (1 > Number(a.zeal?.value ?? 0)) {
errors.push({ code: 'no-zeal', message: 'Not enough zeal', i18nKey: 'HBM.spellCast.notEnoughZeal' });
}
} else if (mode === 'sacred' && opts.hekateMode !== 'witch') {
const zealNeeded = Math.max(1, opts.zealSpent ?? 1);
if (zealNeeded > Number(a.zeal?.value ?? 0)) {
errors.push({ code: 'no-zeal', message: 'Not enough zeal', i18nKey: 'HBM.spellCast.notEnoughZeal' });
}
// Prayer (any spell, sacred by default): Mana + 1 Zapał. A regular spell costs no Zapał.
const asPrayer = resolveCastAsPrayer(spell, opts);
if (asPrayer && 1 > Number(a.zeal?.value ?? 0)) {
errors.push({ code: 'no-zeal', message: 'Not enough zeal', i18nKey: 'HBM.spellCast.notEnoughZeal' });
}
if (mode === 'blood') {
if (bloodSpent > Number(a.blood?.value ?? 0)) {
@@ -210,7 +196,8 @@ export function validateCast(actor: CastableActor, spell: SpellLike, opts: CastO
// Witch symbol cap
if (mode === 'witch' || opts.hekateMode === 'witch') {
const required = Math.max(0, Number(sys.complexityLevel ?? sys.components?.symbols?.length ?? 0));
// Poziom Skomplikowania = number of Symbols in the spell.
const required = Math.max(0, Number(sys.components?.symbols?.length ?? 0));
const maxSymbols = Math.ceil(Number(a.magic?.value ?? 0) / 2);
if (required > maxSymbols) {
errors.push({