From 23c1e94ab58a63f5f62c83f3269d8a7856ac76ac Mon Sep 17 00:00:00 2001 From: Octoturge Date: Thu, 29 Oct 2026 17:19:45 +0100 Subject: [PATCH] wip: 2026-10-26 sync - casting penalties, prayer route, Hartowany, madness threshold (untested) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01E48f1kXhsEbAUxdkNP1yDM --- lang/en.json | 9 +++ lang/pl.json | 9 +++ scripts/parsers/spell-parser.ts | 4 +- src/constants.ts | 2 +- src/data/actor-character.ts | 7 ++- src/data/item-gear.ts | 2 + src/data/item-spell.ts | 4 +- src/dice/cast-dialog.ts | 27 ++++---- src/logic/abyss-magic.ts | 38 ++++++++---- src/logic/chat-cards.ts | 1 + src/logic/damage.ts | 7 ++- src/logic/rules.ts | 106 +++++++++++++++++++++++++++++++- src/logic/spell-cast.ts | 82 ++++++++++++++---------- src/logic/spell-damage.ts | 8 ++- src/logic/spell-validation.ts | 33 +++------- templates/chat/spell-cast.hbs | 2 +- templates/item/gear.hbs | 1 + templates/item/spell.hbs | 1 + 18 files changed, 247 insertions(+), 96 deletions(-) diff --git a/lang/en.json b/lang/en.json index fa225f1..650c8cc 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1,5 +1,8 @@ { "HBM": { + "insanity": { + "illnessRoll": "Madness threshold reached - Mental Illness (d100, re-roll duplicates)" + }, "system": { "name": "Homebrew Magic: RPG v3" }, @@ -138,6 +141,7 @@ "aoeUnit": "Unit", "damage": "Damage", "damageBase": "Base damage", + "damageCategory": "Category Obrażenia (+1 damage)", "damageType": "Damage type", "ignoresArmor": "Ignores armor", "statusEffects": "Status effects", @@ -175,6 +179,7 @@ "artifice": "Artifice", "golemancy": "Golemancy", "runes": "Runic Magic", + "runic": "Runic Magic", "manaSourceMage": "Mana Source", "illusion": "Illusion", "sacred": "Sacred Magic", @@ -260,6 +265,8 @@ "heavy": "Heavy", "shield": "Shield", "stealthDisadvantage": "Stealth Disadvantage", + "ignoresCastingPenalty": "Does not hamper spellcasting", + "ignoresCastingPenaltyHint": "Special case (e.g. Cultist medium armour, Ninurta's Breastplate): skips the casting penalty", "strengthRequirement": "Strength Requirement", "rarity": "Rarity", "value": "Value", @@ -363,6 +370,8 @@ }, "spellCast": { "title": "Casting spell", + "route": "Casting route", + "routeSpell": "Regular spell (Magia + Zdolności Magiczne, Mana only)", "diceModifier": "Dice modifier", "diceModifierHint": "+/- dice in the casting test (the pool never drops below 0)", "manaSpent": "Mana spent", diff --git a/lang/pl.json b/lang/pl.json index 9c12c63..2185899 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -1,5 +1,8 @@ { "HBM": { + "insanity": { + "illnessRoll": "Próg Obłędu osiągnięty - Choroba Psychiczna (k100, powtórz duplikaty)" + }, "system": { "name": "Homebrew Magic: RPG v3" }, @@ -138,6 +141,7 @@ "aoeUnit": "Jednostka", "damage": "Obrażenia", "damageBase": "Obrażenia bazowe", + "damageCategory": "Kategoria Obrażenia (+1 do Obrażeń)", "damageType": "Typ obrażeń", "ignoresArmor": "Ignoruje pancerz", "statusEffects": "Efekty statusu", @@ -175,6 +179,7 @@ "artifice": "Rzemiosło Artefaktów", "golemancy": "Golemancja", "runes": "Magia Runiczna", + "runic": "Magia Runiczna", "manaSourceMage": "Źródło Mocy", "illusion": "Magia Iluzji", "sacred": "Magia Sakralna", @@ -280,6 +285,8 @@ "heavy": "Ciężka", "shield": "Tarcza", "stealthDisadvantage": "Utrudnia Skradanie", + "ignoresCastingPenalty": "Nie utrudnia rzucania zaklęć", + "ignoresCastingPenaltyHint": "Przypadek szczególny (np. Średnia zbroja kultysty, Napierśnik Ninurty): pomija karę do rzucania zaklęć", "strengthRequirement": "Wymóg Siły", "rarity": "Rzadkość", "value": "Wartość", @@ -383,6 +390,8 @@ }, "spellCast": { "title": "Rzucanie zaklęcia", + "route": "Sposób rzucenia", + "routeSpell": "Zwykłe zaklęcie (Magia + Zdolności Magiczne, tylko Mana)", "diceModifier": "Modyfikator liczby kości", "diceModifierHint": "+/- kości w Teście rzucania (liczba kości nie spadnie poniżej 0)", "manaSpent": "Wydana mana", diff --git a/scripts/parsers/spell-parser.ts b/scripts/parsers/spell-parser.ts index 8a2f9a6..d3b2651 100644 --- a/scripts/parsers/spell-parser.ts +++ b/scripts/parsers/spell-parser.ts @@ -19,7 +19,6 @@ const SpellNoteSchema = z.object({ bloodCost: z.number().int().min(0).default(0), threshold: z.number().int().min(1).max(6), successes: z.number().int().min(1), - complexityLevel: z.number().int().min(0).default(0), isSuperspell: z.boolean().default(false), requiresGroupCast: z.boolean().default(false), minCasters: z.number().int().min(1).default(1), @@ -36,6 +35,7 @@ const SpellNoteSchema = z.object({ targets: z.coerce.string().default(''), duration: z.coerce.string().default(''), damageBase: z.coerce.string().default(''), + damageCategory: z.boolean().default(false), damageType: z.string().default('magical'), ignoresArmor: z.boolean().default(false), statusEffects: z.array(z.string()).default([]), @@ -93,6 +93,7 @@ function toSystem(n: SpellNote): Record { duration: n.duration, areaOfEffect: deriveAreaOfEffect(n.targets), damageBase: n.damageBase, + damageCategory: n.damageCategory, damageType: n.damageType || 'magical', ignoresArmor: n.ignoresArmor, statusEffects: n.statusEffects, @@ -105,7 +106,6 @@ function toSystem(n: SpellNote): Record { nonCombatOnly: n.nonCombatOnly, overcastOptions: n.overcast.map((description) => ({ description, manaPerStep: 1 })), triggers: n.triggers, - complexityLevel: n.complexityLevel, }; } diff --git a/src/constants.ts b/src/constants.ts index aea5abc..8c04fef 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -135,7 +135,7 @@ export const SPELL_SCHOOLS = [ // Academic disciplines 'alchemyTransmutation', 'alchemyBrewing', 'botany', 'elementsAir', 'elementsWater', 'elementsFire', 'elementsEarth', - 'artifice', 'golemancy', 'runes', 'manaSourceMage', + 'artifice', 'golemancy', 'runes', 'runic', 'manaSourceMage', 'illusion', 'sacred', 'sacredExorcism', 'witch', 'necromancy', // Forbidden / extra-academic 'blood', 'crimson', 'abyssAspects', 'abyssPrimal', 'wildWitch', diff --git a/src/data/actor-character.ts b/src/data/actor-character.ts index fdf2c86..94e21f8 100644 --- a/src/data/actor-character.ts +++ b/src/data/actor-character.ts @@ -87,9 +87,11 @@ export class CharacterData extends foundry.abstract.TypeDataModel { }), // Wielka Ofiara used today (once per day; reset by Długi Odpoczynek). greatSacrificeUsed: makeIntField(0, { min: 0, max: 1 }), - // Insanity points (Klątwa Otchłani VIII). Each Abyss exposure may add 1; rolls - // mental-condition table when reaching thresholds. + // Punkty Obłędu (Choroby Psychiczne). Reaching Próg Obłędu (Dusza + 3, Krasnolud +1) + // resets them to 0 and gives one illness (k100 table in the vault). insanity: makeIntField(0, { min: 0 }), + // Mental illnesses gained so far (names). The 6th one means the character is lost. + mentalIllnesses: makeArrayField(new f.StringField({ blank: false })), magicalArmor: new f.SchemaField({ value: makeIntField(0, { min: 0 }), max: makeIntField(0, { min: 0 }), @@ -164,6 +166,7 @@ export class CharacterData extends foundry.abstract.TypeDataModel { bloodUsed: { selfHarm: number; lifeSteal: number; sacrifice: number; absorb: number }; greatSacrificeUsed: number; insanity: number; + mentalIllnesses: string[]; magicalArmor: { value: number; max: number; runicCounter: number }; magicalShield: { value: number }; physicalArmor: { value: number; max: number; condition: number; conditionMax: number }; diff --git a/src/data/item-gear.ts b/src/data/item-gear.ts index 980df3b..930e7f4 100644 --- a/src/data/item-gear.ts +++ b/src/data/item-gear.ts @@ -53,6 +53,8 @@ export class GearData extends foundry.abstract.TypeDataModel { }), stealthDisadvantage: makeBoolField(false), strengthRequirement: makeIntField(0, { min: 0 }), + // Special-case armour that never hampers casting (Średnia zbroja kultysty, Napierśnik Ninurty). + ignoresCastingPenalty: makeBoolField(false), }), }; } diff --git a/src/data/item-spell.ts b/src/data/item-spell.ts index 2c76c14..ac7d2c4 100644 --- a/src/data/item-spell.ts +++ b/src/data/item-spell.ts @@ -63,6 +63,8 @@ export class SpellData extends foundry.abstract.TypeDataModel { // Damage damageBase: makeStringField(''), + // Category Obrażenia: +1 damage (Rodzaje Obrażeń). Vault frontmatter has no category yet. + damageCategory: makeBoolField(false), damageType: makeStringField('magical', { blank: false, choices: SPELL_DAMAGE_TYPES as unknown as readonly string[] }), ignoresArmor: makeBoolField(false), @@ -108,7 +110,7 @@ export class SpellData extends foundry.abstract.TypeDataModel { // Legacy fields (kept for migration; will be removed in a later release) overcasting: makeStringField(''), - complexityLevel: makeIntField(0, { min: 0, max: 10 }), + // `complexityLevel` was removed 2026-10-26: Poziom Skomplikowania = number of Symbols. }; } } diff --git a/src/dice/cast-dialog.ts b/src/dice/cast-dialog.ts index 29cd61a..bd569db 100644 --- a/src/dice/cast-dialog.ts +++ b/src/dice/cast-dialog.ts @@ -4,7 +4,8 @@ * * Inputs adapt to spell mode: * - standard: mana - * - sacred: zeal (+ optional Hekate hybrid toggle if witch symbols set) + * - sacred: mana (+ optional Hekate hybrid toggle if witch symbols set) + * - all: regular spell / prayer route (sacred defaults to prayer) * - witch: mana * - blood: mana + blood * @@ -43,7 +44,6 @@ export async function askCastOptions(spell: SpellForDialog, actor: ActorForDialo const a = actor.system.attributes; const baseMana = Math.max(0, spell.system.manaCost ?? 0); const baseBlood = Math.max(0, spell.system.bloodCost ?? 0); - const baseZeal = mode === 'sacred' ? 1 : 0; const showHekate = mode === 'sacred' && (spell.system.components?.symbols?.length ?? 0) > 0; const showSuper = !!spell.system.isSuperspell; @@ -52,7 +52,7 @@ export async function askCastOptions(spell: SpellForDialog, actor: ActorForDialo const fields: string[] = []; - if (mode === 'standard' || mode === 'witch' || mode === 'blood') { + if (mode === 'standard' || mode === 'witch' || mode === 'blood' || mode === 'sacred') { const max = a.mana.value; fields.push(`
@@ -61,13 +61,6 @@ export async function askCastOptions(spell: SpellForDialog, actor: ActorForDialo ${game.i18n.localize('HBM.resources.maxManaPerSpell')}: ${a.mana.maxPerSpell}
`); } - if (mode === 'sacred') { - fields.push(` -
- - -
`); - } if (mode === 'blood' && a.blood) { const max = a.blood.value; fields.push(` @@ -82,12 +75,15 @@ export async function askCastOptions(spell: SpellForDialog, actor: ActorForDialo ${game.i18n.localize('HBM.spellCast.diceModifierHint')} `); - if (mode !== 'sacred') { - fields.push(` + // Every known spell can be cast as a regular spell or as a prayer; sacred spells default to a prayer. + fields.push(`
- + +
`); - } if (showHekate) { fields.push(`
@@ -166,12 +162,11 @@ export async function askCastOptions(spell: SpellForDialog, actor: ActorForDialo } opts.manaSpent = Math.max(baseMana, spent); } - 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; + opts.castAsPrayer = fd.castRoute === 'prayer'; if (fd.bypassSuperspellWarning) opts.bypassSuperspellWarning = true; if (fd.bypassNonCombatBlock) opts.bypassNonCombatBlock = true; const groupRaw = String(fd.groupCasters ?? '').trim(); diff --git a/src/logic/abyss-magic.ts b/src/logic/abyss-magic.ts index 1df4da1..b8b96d3 100644 --- a/src/logic/abyss-magic.ts +++ b/src/logic/abyss-magic.ts @@ -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. */ diff --git a/src/logic/chat-cards.ts b/src/logic/chat-cards.ts index 8eaa90d..7601994 100644 --- a/src/logic/chat-cards.ts +++ b/src/logic/chat-cards.ts @@ -85,6 +85,7 @@ async function onApplyDamage(btn: HTMLElement): Promise { shieldMultiplier: profile?.shieldMultiplier, destroyMagicalDefenses: profile?.destroyMagicalDefenses, minDamageAfterArmor: btn.dataset.minAfterArmor === 'true', + fire: btn.dataset.fire === 'true', } as any); } diff --git a/src/logic/damage.ts b/src/logic/damage.ts index 6fa8344..dd19af2 100644 --- a/src/logic/damage.ts +++ b/src/logic/damage.ts @@ -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 { - 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 = {}; const report: DamageReport = { diff --git a/src/logic/rules.ts b/src/logic/rules.ts index 6e1c247..016a61f 100644 --- a/src/logic/rules.ts +++ b/src/logic/rules.ts @@ -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 | 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. diff --git a/src/logic/spell-cast.ts b/src/logic/spell-cast.ts index 300e6e9..e4ebe2e 100644 --- a/src/logic/spell-cast.ts +++ b/src/logic/spell-cast.ts @@ -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 { +/** 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 | 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 { + 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 { 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 { - 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 { 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 = { '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')}` diff --git a/src/logic/spell-damage.ts b/src/logic/spell-damage.ts index 287936c..e8fe510 100644 --- a/src/logic/spell-damage.ts +++ b/src/logic/spell-damage.ts @@ -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 { diff --git a/src/logic/spell-validation.ts b/src/logic/spell-validation.ts index d7df853..7e2e6a3 100644 --- a/src/logic/spell-validation.ts +++ b/src/logic/spell-validation.ts @@ -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 | 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({ diff --git a/templates/chat/spell-cast.hbs b/templates/chat/spell-cast.hbs index b4c8c54..1547e03 100644 --- a/templates/chat/spell-cast.hbs +++ b/templates/chat/spell-cast.hbs @@ -51,7 +51,7 @@ {{#if damage.targets}}
{{#each damage.targets as |t|}} - {{/each}} diff --git a/templates/item/gear.hbs b/templates/item/gear.hbs index bdf58a4..9413775 100644 --- a/templates/item/gear.hbs +++ b/templates/item/gear.hbs @@ -57,6 +57,7 @@ + {{/if}} diff --git a/templates/item/spell.hbs b/templates/item/spell.hbs index c1c6a3a..92a6772 100644 --- a/templates/item/spell.hbs +++ b/templates/item/spell.hbs @@ -148,6 +148,7 @@ +