9 Commits
Author SHA1 Message Date
octoturgeandClaude Sonnet 5.5 5b1d57165e feat: derive Obrażenia +1 damageCategory from vault spell category
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E48f1kXhsEbAUxdkNP1yDM
2026-10-30 08:11:35 +01:00
octoturgeandClaude Sonnet 5.5 d9d94a465c feat: Zapał regen block flag, tests, changelog for 2026-10-26 sync (partial)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E48f1kXhsEbAUxdkNP1yDM
2026-10-29 22:55:34 +01:00
octoturgeandClaude Sonnet 5.5 23c1e94ab5 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
2026-10-29 17:19:45 +01:00
octoturge c5e53e591a Merge fix/en-cannot-be-dispelled 2026-10-29 17:13:06 +01:00
octoturge da236ca069 Merge feat/cast-dice-modifier 2026-10-29 17:12:21 +01:00
octoturgeandClaude Sonnet 5.5 9321f9cb5b fix(lang): Cannot be unraveled -> Cannot be dispelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E48f1kXhsEbAUxdkNP1yDM
2026-10-26 21:50:38 +01:00
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
octoturgeandClaude Sonnet 5.5 2c3781a2e8 fix: new-round chat message no longer claims a Mana reset
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E48f1kXhsEbAUxdkNP1yDM
2026-10-26 21:15:32 +01:00
octoturgeandClaude Sonnet 5.5 9f93745c42 fix: Egzorcyzmy count as Magia Sakralna for spell discipline gate (1.5.2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E48f1kXhsEbAUxdkNP1yDM
2026-10-26 21:08:20 +01:00
27 changed files with 390 additions and 107 deletions
+27
View File
@@ -5,6 +5,33 @@ 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).
## [Unreleased] - 2026-10 sync (partial, Foundry Sync Brief 2026-10-26)
### Added
- Casting penalties from armour: Średni −1 die, Ciężki −3 dice and +2 Mana (also overcast), Tarcza −1 die (stacks); Pancerz Wiary removes armour but not shield penalties. Gear flag `armor.ignoresCastingPenalty` for special-case armour.
- Every spell, sacred ones included, can be cast as a regular spell or as a prayer (cast dialog "Sposób rzucenia"). Prayer = Dusza + Oddanie, Mana + 1 Zapał; sacred spells default to prayer.
- `damageCategory` spell flag: +1 damage for category Obrażenia (off by default; vault frontmatter has no category yet).
- `runic` spell school (alias of the `runes` discipline for the discipline gate).
- Pure helpers in `rules.ts`: `golemLimit`, `fallDamage`, `survivalTest`, `zealRegenBlocked`, `madnessThreshold` (not all wired yet).
- Characters have `mentalIllnesses`.
### Changed
- Wiedźmia Magia: Poziom Skomplikowania = number of Symbols; `complexityLevel` removed from spells.
- Hartowany w Ogniu: flat −2 per fire hit (min 0) instead of halving.
- Próg Obłędu = Dusza + 3 (Krasnolud +1); reaching it resets Punkty Obłędu and rolls the illness k100. Replaces the old 3/6/10/15 thresholds.
## [1.5.2] - 2026-10-26
### Fixed
- 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.
- 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.
### 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`.
### Changed
- English text "Cannot be unraveled" is now "Cannot be dispelled" on the spell cast card.
## [1.5.1] - 2026-10-22 ## [1.5.1] - 2026-10-22
### Added ### Added
+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 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. - **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.
- **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).
+13 -2
View File
@@ -1,5 +1,8 @@
{ {
"HBM": { "HBM": {
"insanity": {
"illnessRoll": "Madness threshold reached - Mental Illness (d100, re-roll duplicates)"
},
"system": { "system": {
"name": "Homebrew Magic: RPG v3" "name": "Homebrew Magic: RPG v3"
}, },
@@ -138,6 +141,7 @@
"aoeUnit": "Unit", "aoeUnit": "Unit",
"damage": "Damage", "damage": "Damage",
"damageBase": "Base damage", "damageBase": "Base damage",
"damageCategory": "Category Obrażenia (+1 damage)",
"damageType": "Damage type", "damageType": "Damage type",
"ignoresArmor": "Ignores armor", "ignoresArmor": "Ignores armor",
"statusEffects": "Status effects", "statusEffects": "Status effects",
@@ -175,6 +179,7 @@
"artifice": "Artifice", "artifice": "Artifice",
"golemancy": "Golemancy", "golemancy": "Golemancy",
"runes": "Runic Magic", "runes": "Runic Magic",
"runic": "Runic Magic",
"manaSourceMage": "Mana Source", "manaSourceMage": "Mana Source",
"illusion": "Illusion", "illusion": "Illusion",
"sacred": "Sacred Magic", "sacred": "Sacred Magic",
@@ -260,6 +265,8 @@
"heavy": "Heavy", "heavy": "Heavy",
"shield": "Shield", "shield": "Shield",
"stealthDisadvantage": "Stealth Disadvantage", "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", "strengthRequirement": "Strength Requirement",
"rarity": "Rarity", "rarity": "Rarity",
"value": "Value", "value": "Value",
@@ -363,8 +370,12 @@
}, },
"spellCast": { "spellCast": {
"title": "Casting spell", "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", "manaSpent": "Mana spent",
"cannotBeUnraveled": "Cannot be unraveled", "cannotBeUnraveled": "Cannot be dispelled",
"zealSpent": "Zeal spent", "zealSpent": "Zeal spent",
"bloodSpent": "Blood spent", "bloodSpent": "Blood spent",
"hekateMode": "Hekate mode (Sacred + Witch hybrid)", "hekateMode": "Hekate mode (Sacred + Witch hybrid)",
@@ -423,7 +434,7 @@
} }
}, },
"combat": { "combat": {
"newRound": "New round - Mana reset", "newRound": "New round - Immediate Action and Krew counters 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)"
+12 -1
View File
@@ -1,5 +1,8 @@
{ {
"HBM": { "HBM": {
"insanity": {
"illnessRoll": "Próg Obłędu osiągnięty - Choroba Psychiczna (k100, powtórz duplikaty)"
},
"system": { "system": {
"name": "Homebrew Magic: RPG v3" "name": "Homebrew Magic: RPG v3"
}, },
@@ -138,6 +141,7 @@
"aoeUnit": "Jednostka", "aoeUnit": "Jednostka",
"damage": "Obrażenia", "damage": "Obrażenia",
"damageBase": "Obrażenia bazowe", "damageBase": "Obrażenia bazowe",
"damageCategory": "Kategoria Obrażenia (+1 do Obrażeń)",
"damageType": "Typ obrażeń", "damageType": "Typ obrażeń",
"ignoresArmor": "Ignoruje pancerz", "ignoresArmor": "Ignoruje pancerz",
"statusEffects": "Efekty statusu", "statusEffects": "Efekty statusu",
@@ -175,6 +179,7 @@
"artifice": "Rzemiosło Artefaktów", "artifice": "Rzemiosło Artefaktów",
"golemancy": "Golemancja", "golemancy": "Golemancja",
"runes": "Magia Runiczna", "runes": "Magia Runiczna",
"runic": "Magia Runiczna",
"manaSourceMage": "Źródło Mocy", "manaSourceMage": "Źródło Mocy",
"illusion": "Magia Iluzji", "illusion": "Magia Iluzji",
"sacred": "Magia Sakralna", "sacred": "Magia Sakralna",
@@ -280,6 +285,8 @@
"heavy": "Ciężka", "heavy": "Ciężka",
"shield": "Tarcza", "shield": "Tarcza",
"stealthDisadvantage": "Utrudnia Skradanie", "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", "strengthRequirement": "Wymóg Siły",
"rarity": "Rzadkość", "rarity": "Rzadkość",
"value": "Wartość", "value": "Wartość",
@@ -383,6 +390,10 @@
}, },
"spellCast": { "spellCast": {
"title": "Rzucanie zaklęcia", "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", "manaSpent": "Wydana mana",
"cannotBeUnraveled": "Nie da się rozpleść", "cannotBeUnraveled": "Nie da się rozpleść",
"cast": "Rzuć", "cast": "Rzuć",
@@ -426,7 +437,7 @@
} }
}, },
"combat": { "combat": {
"newRound": "Nowa runda - Mana zresetowana", "newRound": "Nowa runda - liczniki Akcji Natychmiastowych i Krwi zresetowane",
"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.1", "version": "1.5.2",
"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",
+4 -2
View File
@@ -19,7 +19,6 @@ const SpellNoteSchema = z.object({
bloodCost: z.number().int().min(0).default(0), bloodCost: z.number().int().min(0).default(0),
threshold: z.number().int().min(1).max(6), threshold: z.number().int().min(1).max(6),
successes: z.number().int().min(1), successes: z.number().int().min(1),
complexityLevel: z.number().int().min(0).default(0),
isSuperspell: z.boolean().default(false), isSuperspell: z.boolean().default(false),
requiresGroupCast: z.boolean().default(false), requiresGroupCast: z.boolean().default(false),
minCasters: z.number().int().min(1).default(1), minCasters: z.number().int().min(1).default(1),
@@ -36,6 +35,8 @@ const SpellNoteSchema = z.object({
targets: z.coerce.string().default(''), targets: z.coerce.string().default(''),
duration: z.coerce.string().default(''), duration: z.coerce.string().default(''),
damageBase: z.coerce.string().default(''), damageBase: z.coerce.string().default(''),
category: z.coerce.string().default(''),
damageCategory: z.boolean().default(false),
damageType: z.string().default('magical'), damageType: z.string().default('magical'),
ignoresArmor: z.boolean().default(false), ignoresArmor: z.boolean().default(false),
statusEffects: z.array(z.string()).default([]), statusEffects: z.array(z.string()).default([]),
@@ -93,6 +94,8 @@ function toSystem(n: SpellNote): Record<string, unknown> {
duration: n.duration, duration: n.duration,
areaOfEffect: deriveAreaOfEffect(n.targets), areaOfEffect: deriveAreaOfEffect(n.targets),
damageBase: n.damageBase, damageBase: n.damageBase,
// Spells of the "Obrażenia" effect category (vault `category:`) deal +1 damage.
damageCategory: n.damageCategory || n.category.trim().toLowerCase() === 'obrażenia',
damageType: n.damageType || 'magical', damageType: n.damageType || 'magical',
ignoresArmor: n.ignoresArmor, ignoresArmor: n.ignoresArmor,
statusEffects: n.statusEffects, statusEffects: n.statusEffects,
@@ -105,7 +108,6 @@ function toSystem(n: SpellNote): Record<string, unknown> {
nonCombatOnly: n.nonCombatOnly, nonCombatOnly: n.nonCombatOnly,
overcastOptions: n.overcast.map((description) => ({ description, manaPerStep: 1 })), overcastOptions: n.overcast.map((description) => ({ description, manaPerStep: 1 })),
triggers: n.triggers, triggers: n.triggers,
complexityLevel: n.complexityLevel,
}; };
} }
+1 -1
View File
@@ -135,7 +135,7 @@ export const SPELL_SCHOOLS = [
// Academic disciplines // Academic disciplines
'alchemyTransmutation', 'alchemyBrewing', 'botany', 'alchemyTransmutation', 'alchemyBrewing', 'botany',
'elementsAir', 'elementsWater', 'elementsFire', 'elementsEarth', 'elementsAir', 'elementsWater', 'elementsFire', 'elementsEarth',
'artifice', 'golemancy', 'runes', 'manaSourceMage', 'artifice', 'golemancy', 'runes', 'runic', 'manaSourceMage',
'illusion', 'sacred', 'sacredExorcism', 'witch', 'necromancy', 'illusion', 'sacred', 'sacredExorcism', 'witch', 'necromancy',
// Forbidden / extra-academic // Forbidden / extra-academic
'blood', 'crimson', 'abyssAspects', 'abyssPrimal', 'wildWitch', 'blood', 'crimson', 'abyssAspects', 'abyssPrimal', 'wildWitch',
+5 -2
View File
@@ -87,9 +87,11 @@ export class CharacterData extends foundry.abstract.TypeDataModel {
}), }),
// Wielka Ofiara used today (once per day; reset by Długi Odpoczynek). // Wielka Ofiara used today (once per day; reset by Długi Odpoczynek).
greatSacrificeUsed: makeIntField(0, { min: 0, max: 1 }), greatSacrificeUsed: makeIntField(0, { min: 0, max: 1 }),
// Insanity points (Klątwa Otchłani VIII). Each Abyss exposure may add 1; rolls // Punkty Obłędu (Choroby Psychiczne). Reaching Próg Obłędu (Dusza + 3, Krasnolud +1)
// mental-condition table when reaching thresholds. // resets them to 0 and gives one illness (k100 table in the vault).
insanity: makeIntField(0, { min: 0 }), 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({ magicalArmor: new f.SchemaField({
value: makeIntField(0, { min: 0 }), value: makeIntField(0, { min: 0 }),
max: 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 }; bloodUsed: { selfHarm: number; lifeSteal: number; sacrifice: number; absorb: number };
greatSacrificeUsed: number; greatSacrificeUsed: number;
insanity: number; insanity: number;
mentalIllnesses: string[];
magicalArmor: { value: number; max: number; runicCounter: number }; magicalArmor: { value: number; max: number; runicCounter: number };
magicalShield: { value: number }; magicalShield: { value: number };
physicalArmor: { value: number; max: number; condition: number; conditionMax: number }; physicalArmor: { value: number; max: number; condition: number; conditionMax: number };
+2
View File
@@ -53,6 +53,8 @@ export class GearData extends foundry.abstract.TypeDataModel {
}), }),
stealthDisadvantage: makeBoolField(false), stealthDisadvantage: makeBoolField(false),
strengthRequirement: makeIntField(0, { min: 0 }), strengthRequirement: makeIntField(0, { min: 0 }),
// Special-case armour that never hampers casting (Średnia zbroja kultysty, Napierśnik Ninurty).
ignoresCastingPenalty: makeBoolField(false),
}), }),
}; };
} }
+3 -1
View File
@@ -63,6 +63,8 @@ export class SpellData extends foundry.abstract.TypeDataModel {
// Damage // Damage
damageBase: makeStringField(''), 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[] }), damageType: makeStringField('magical', { blank: false, choices: SPELL_DAMAGE_TYPES as unknown as readonly string[] }),
ignoresArmor: makeBoolField(false), 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) // Legacy fields (kept for migration; will be removed in a later release)
overcasting: makeStringField(''), overcasting: makeStringField(''),
complexityLevel: makeIntField(0, { min: 0, max: 10 }), // `complexityLevel` was removed 2026-10-26: Poziom Skomplikowania = number of Symbols.
}; };
} }
} }
+18 -15
View File
@@ -4,7 +4,8 @@
* *
* Inputs adapt to spell mode: * Inputs adapt to spell mode:
* - standard: mana * - 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 * - witch: mana
* - blood: mana + blood * - blood: mana + blood
* *
@@ -43,7 +44,6 @@ export async function askCastOptions(spell: SpellForDialog, actor: ActorForDialo
const a = actor.system.attributes; const a = actor.system.attributes;
const baseMana = Math.max(0, spell.system.manaCost ?? 0); const baseMana = Math.max(0, spell.system.manaCost ?? 0);
const baseBlood = Math.max(0, spell.system.bloodCost ?? 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 showHekate = mode === 'sacred' && (spell.system.components?.symbols?.length ?? 0) > 0;
const showSuper = !!spell.system.isSuperspell; const showSuper = !!spell.system.isSuperspell;
@@ -52,7 +52,7 @@ export async function askCastOptions(spell: SpellForDialog, actor: ActorForDialo
const fields: string[] = []; const fields: string[] = [];
if (mode === 'standard' || mode === 'witch' || mode === 'blood') { if (mode === 'standard' || mode === 'witch' || mode === 'blood' || mode === 'sacred') {
const max = a.mana.value; const max = a.mana.value;
fields.push(` fields.push(`
<div class="form-group"> <div class="form-group">
@@ -61,13 +61,6 @@ export async function askCastOptions(spell: SpellForDialog, actor: ActorForDialo
<small>${game.i18n.localize('HBM.resources.maxManaPerSpell')}: ${a.mana.maxPerSpell}</small> <small>${game.i18n.localize('HBM.resources.maxManaPerSpell')}: ${a.mana.maxPerSpell}</small>
</div>`); </div>`);
} }
if (mode === 'sacred') {
fields.push(`
<div class="form-group">
<label>${game.i18n.localize('HBM.spellCast.zealSpent')} (max ${a.zeal.value})</label>
<input type="number" name="zealSpent" value="${baseZeal}" min="1" max="${a.zeal.value}"/>
</div>`);
}
if (mode === 'blood' && a.blood) { if (mode === 'blood' && a.blood) {
const max = a.blood.value; const max = a.blood.value;
fields.push(` fields.push(`
@@ -76,12 +69,21 @@ 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>`);
} }
if (mode !== 'sacred') {
fields.push(` fields.push(`
<div class="form-group"> <div class="form-group">
<label><input type="checkbox" name="castAsPrayer"/> ${game.i18n.localize('HBM.spell.castingMode.prayer')} (+1 ${game.i18n.localize('HBM.resources.zealAbbr')}, ${game.i18n.localize('HBM.attributes.soul')} + ${game.i18n.localize('HBM.skills.devotion')})</label> <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>`);
// Every known spell can be cast as a regular spell or as a prayer; sacred spells default to a prayer.
fields.push(`
<div class="form-group">
<label>${game.i18n.localize('HBM.spellCast.route')}</label>
<select name="castRoute">
<option value="spell" ${mode === 'sacred' ? '' : 'selected'}>${game.i18n.localize('HBM.spellCast.routeSpell')}</option>
<option value="prayer" ${mode === 'sacred' ? 'selected' : ''}>${game.i18n.localize('HBM.spell.castingMode.prayer')} (+1 ${game.i18n.localize('HBM.resources.zealAbbr')}, ${game.i18n.localize('HBM.attributes.soul')} + ${game.i18n.localize('HBM.skills.devotion')})</option>
</select>
</div>`); </div>`);
}
if (showHekate) { if (showHekate) {
fields.push(` fields.push(`
<div class="form-group"> <div class="form-group">
@@ -160,10 +162,11 @@ export async function askCastOptions(spell: SpellForDialog, actor: ActorForDialo
} }
opts.manaSpent = Math.max(baseMana, spent); 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); 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; opts.castAsPrayer = fd.castRoute === 'prayer';
if (fd.bypassSuperspellWarning) opts.bypassSuperspellWarning = true; if (fd.bypassSuperspellWarning) opts.bypassSuperspellWarning = true;
if (fd.bypassNonCombatBlock) opts.bypassNonCombatBlock = true; if (fd.bypassNonCombatBlock) opts.bypassNonCombatBlock = true;
const groupRaw = String(fd.groupCasters ?? '').trim(); const groupRaw = String(fd.groupCasters ?? '').trim();
+25 -11
View File
@@ -12,12 +12,12 @@
* when present; otherwise falls back to a chat * when present; otherwise falls back to a chat
* prompt for the GM). * prompt for the GM).
* - rollMistrzLosuPenalty(actor) - d100 penalty roll (Klątwa Otchłani VII). * - rollMistrzLosuPenalty(actor) - d100 penalty roll (Klątwa Otchłani VII).
* - addInsanity(actor, n) - increments actor.system.attributes.insanity; * - addInsanity(actor, n) - adds Punkty Obłędu; at Próg Obłędu resets them,
* when crossing a threshold, fires a hook for * rolls the illness k100 and fires `hbm.mentalIllness`.
* the GM to apply a mental condition.
*/ */
import type { CastableActor } from './spell-cast'; import type { CastableActor } from './spell-cast';
import { madnessThreshold } from './rules';
export type AbyssDiscipline = 'aspects' | 'primal'; export type AbyssDiscipline = 'aspects' | 'primal';
@@ -40,19 +40,33 @@ export function dispatchAbyssCast(spell: SpellLikeForAbyss): AbyssDiscipline {
return 'aspects'; 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[] }> { export async function addInsanity(actor: CastableActor, n: number): Promise<{ before: number; after: number; thresholdsCrossed: number[] }> {
if (n <= 0) return { before: 0, after: 0, thresholdsCrossed: [] }; 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; const after = before + n;
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 }); await actor.update({ 'system.attributes.insanity': after });
const crossed = INSANITY_THRESHOLDS.filter((t) => before < t && after >= t); return { before, after, thresholdsCrossed: [] };
if (crossed.length > 0) {
Hooks.callAll('hbm.insanityThreshold', actor, crossed, { before, after });
} }
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. */ /** 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, shieldMultiplier: profile?.shieldMultiplier,
destroyMagicalDefenses: profile?.destroyMagicalDefenses, destroyMagicalDefenses: profile?.destroyMagicalDefenses,
minDamageAfterArmor: btn.dataset.minAfterArmor === 'true', minDamageAfterArmor: btn.dataset.minAfterArmor === 'true',
fire: btn.dataset.fire === 'true',
} as any); } as any);
} }
+5
View File
@@ -123,7 +123,12 @@ export function registerCombatHooks(): void {
} }
// Per-turn Zeal regen (characters) - base 1 + talent bonus from flag. // Per-turn Zeal regen (characters) - base 1 + talent bonus from flag.
// Blocked when since the previous turn the actor paid for an extra Action or an extra Immediate Action
// (flag `paidExtra`, set by whoever spends the Zapał; prayers / own-cost Talents never set it).
if (actor.type === 'character') { if (actor.type === 'character') {
const blocked = Boolean(actor.getFlag?.(FLAG_SCOPE, 'paidExtra'));
if (actor.getFlag?.(FLAG_SCOPE, 'paidExtra')) await actor.unsetFlag?.(FLAG_SCOPE, 'paidExtra');
if (blocked) return;
const sys = actor.system as { attributes: { zeal: { value: number; max: number } } }; const sys = actor.system as { attributes: { zeal: { value: number; max: number } } };
const regen = getZealRegen(actor); const regen = getZealRegen(actor);
const next = Math.min(sys.attributes.zeal.max, sys.attributes.zeal.value + regen); const next = Math.min(sys.attributes.zeal.max, sys.attributes.zeal.value + regen);
+5 -2
View File
@@ -14,7 +14,7 @@
*/ */
import { DamageType } from '../constants'; import { DamageType } from '../constants';
import { triggersStun, concentrationTest, runicArmorPointsDisabled } from './rules'; import { triggersStun, concentrationTest, runicArmorPointsDisabled, hardenedFireDamage, actorHasTalent } from './rules';
import { gainKrewFromWeaponDamage } from './blood-magic'; import { gainKrewFromWeaponDamage } from './blood-magic';
export interface ApplyDamageOptions { export interface ApplyDamageOptions {
@@ -39,6 +39,8 @@ export interface ApplyDamageOptions {
woundKinds?: ('slashing' | 'piercing' | 'blunt')[]; woundKinds?: ('slashing' | 'piercing' | 'blunt')[];
/** Attacker, for passive Krew gain (Szacunek do Życia / Krwisty Pojedynkowicz). */ /** Attacker, for passive Krew gain (Szacunek do Życia / Krwisty Pojedynkowicz). */
sourceActor?: any; sourceActor?: any;
/** Fire damage: a Hartowany w Ogniu target takes 2 less per hit (min 0). */
fire?: boolean;
postChat?: boolean; postChat?: boolean;
} }
@@ -68,7 +70,8 @@ interface ActorLike {
} }
export async function applyDamage(actor: ActorLike, opts: ApplyDamageOptions): Promise<DamageReport> { 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 a = actor.system.attributes;
const updates: Record<string, unknown> = {}; const updates: Record<string, unknown> = {};
const report: DamageReport = { const report: DamageReport = {
+109 -2
View File
@@ -4,8 +4,8 @@
* *
* Rounding convention: everything fractional rounds UP, except * Rounding convention: everything fractional rounds UP, except
* - new-spell mana cost (nearest, min 1), * - new-spell mana cost (nearest, min 1),
* - Nadczarowanie bonus from level 3 (halved, DOWN, min 1), * - Nadczarowanie bonus from level 3 (halved, DOWN, min 1).
* - fire damage with Hartowany w Ogniu (halved, DOWN). * (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. */ /** 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); 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). * 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. * Default: a hit of more than 5 damage (after the Runiczny Pancerz step) disables 1 point.
@@ -259,3 +361,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));
}
+57 -36
View File
@@ -16,7 +16,8 @@
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, castingArmorPenalty, actorHasTalent, resolveCastAsPrayer, SPELL_DAMAGE_PROFILES } from './rules';
import type { CastingArmorPenalty } 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';
@@ -49,12 +50,13 @@ export interface SpellLike {
deity?: string; deity?: string;
manaCost: number; manaCost: number;
bloodCost?: number; bloodCost?: number;
complexityLevel: number;
isSuperspell?: boolean; isSuperspell?: boolean;
requiresGroupCast?: boolean; requiresGroupCast?: boolean;
minCasters?: number; minCasters?: number;
nonCombatOnly?: boolean; nonCombatOnly?: boolean;
damageBase?: string; damageBase?: string;
/** Spell category is Obrażenia (+1 damage). Not carried by vault frontmatter yet; set per spell. */
damageCategory?: boolean;
damageType?: string; damageType?: string;
ignoresArmor?: boolean; ignoresArmor?: boolean;
statusEffects?: string[]; statusEffects?: string[];
@@ -72,10 +74,17 @@ export interface CastOptions {
zealSpent?: number; zealSpent?: number;
bloodSpent?: number; bloodSpent?: number;
hekateMode?: 'sacred' | 'witch'; 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; castAsPrayer?: boolean;
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;
@@ -109,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 // 1. Validate
const validation = validateCast(actor, spell, opts); const validation = validateCast(actor, spell, opts);
if (!validation.ok) { if (!validation.ok) {
@@ -127,9 +151,10 @@ export async function castSpell(actor: CastableActor, spell: SpellLike, opts: Ca
let baseRoll: HbmTSRoll | null = null; let baseRoll: HbmTSRoll | null = null;
const rolls: CastRollEntry[] = []; const rolls: CastRollEntry[] = [];
if (mode === 'sacred') { if (mode === 'sacred') {
// Sacred spells are cast like any other spell: as a prayer (default) or as a regular spell.
baseRoll = opts.hekateMode === 'witch' baseRoll = opts.hekateMode === 'witch'
? await castWitch(actor, spell, opts, rolls) ? await castWitch(actor, spell, opts, rolls)
: await castSacred(actor, spell, opts, rolls); : await castStandard(actor, spell, opts, rolls);
} else if (mode === 'witch') { } else if (mode === 'witch') {
baseRoll = await castWitch(actor, spell, opts, rolls); baseRoll = await castWitch(actor, spell, opts, rolls);
} else if (mode === 'blood') { } else if (mode === 'blood') {
@@ -144,7 +169,11 @@ export async function castSpell(actor: CastableActor, spell: SpellLike, opts: Ca
if (baseRoll?.ts?.isSuccess) { if (baseRoll?.ts?.isSuccess) {
// 4. Damage // 4. Damage
if (spell.system.damageBase) { 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 // 6. Triggers
@@ -198,6 +227,8 @@ async function renderCastCard(
})), })),
type: spell.system.damageType ?? 'magical', type: spell.system.damageType ?? 'magical',
ignoresArmor: !!spell.system.ignoresArmor, 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. // 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', 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. // Shield-breakers / Szkarłatny Pocisk / Prawdziwa Dezintegracja treat the magical defences specially.
@@ -258,7 +289,9 @@ async function renderCastCard(
async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOptions, rolls: CastRollEntry[]): Promise<HbmTSRoll | null> { async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOptions, rolls: CastRollEntry[]): Promise<HbmTSRoll | null> {
const baseCost = Math.max(0, spell.system.manaCost ?? 0); 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; const a = actor.system.attributes;
// Casting beyond the remaining daily pool is allowed: the missing mana is // Casting beyond the remaining daily pool is allowed: the missing mana is
@@ -276,9 +309,11 @@ 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 // 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) ? 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 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}`;
@@ -344,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 = a.soul.value + (actor.system.skills?.devotion?.value ?? 0);
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> { async function castWitch(actor: CastableActor, spell: SpellLike, opts: CastOptions, rolls: CastRollEntry[]): Promise<HbmTSRoll | null> {
const a = actor.system.attributes; 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 maxSymbols = Math.ceil(a.magic.actual / 2);
const armorPenalty = castingPenaltyFor(actor);
// Witch magic still has a mana cost. // Witch magic still has a mana cost (+2 in Ciężki armour).
const baseCost = Math.max(0, spell.system.manaCost ?? 0); const baseCost = Math.max(0, spell.system.manaCost ?? 0) + armorPenalty.mana;
const updates: Record<string, any> = { 'system.attributes.mana.value': a.mana.value - baseCost }; const updates: Record<string, any> = { 'system.attributes.mana.value': a.mana.value - baseCost };
if (opts.castAsPrayer) { if (opts.castAsPrayer) {
updates['system.attributes.zeal.value'] = a.zeal.value - 1; updates['system.attributes.zeal.value'] = a.zeal.value - 1;
@@ -379,9 +397,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) - armorPenalty.dice;
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})`;
@@ -408,8 +427,9 @@ async function castBlood(actor: CastableActor, spell: SpellLike, opts: CastOptio
updates['system.attributes.blood.value'] = Math.max(0, blood.value - bloodSpent); updates['system.attributes.blood.value'] = Math.max(0, blood.value - bloodSpent);
} }
// Mana cost (some blood spells may also have one) // Mana cost (some blood spells may also have one; +2 in Ciężki armour)
const manaCost = Math.max(0, spell.system.manaCost ?? 0); 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) { if (manaCost > 0) {
updates['system.attributes.mana.value'] = a.mana.value - manaCost; updates['system.attributes.mana.value'] = a.mana.value - manaCost;
} }
@@ -421,9 +441,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) - armorPenalty.dice;
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')}`;
+7 -1
View File
@@ -13,10 +13,16 @@
*/ */
import type { CastableActor } from './spell-cast'; import type { CastableActor } from './spell-cast';
import { DAMAGE_CATEGORY_BONUS } from './rules';
export interface DamageContext { export interface DamageContext {
actor: CastableActor; actor: CastableActor;
spellName?: string; 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']); 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 String(v);
}); });
return formula; return ctx.categoryBonus ? `(${formula}) + ${DAMAGE_CATEGORY_BONUS}` : formula;
} }
export async function rollSpellDamage(spellDamageBase: string, ctx: DamageContext): Promise<Roll | null> { export async function rollSpellDamage(spellDamageBase: string, ctx: DamageContext): Promise<Roll | null> {
+14 -23
View File
@@ -5,6 +5,7 @@
*/ */
import type { CastableActor, SpellLike, CastOptions } from './spell-cast'; import type { CastableActor, SpellLike, CastOptions } from './spell-cast';
import { resolveCastAsPrayer, actorHasTalent } from './rules';
export interface ValidationIssue { export interface ValidationIssue {
code: string; code: string;
@@ -29,19 +30,7 @@ function getActorRaceId(actor: CastableActor): string {
return String(d.raceId ?? d.race ?? '').toLowerCase(); return String(d.raceId ?? d.race ?? '').toLowerCase();
} }
function actorHasTalent(actor: CastableActor, talentId: string): boolean { export function matchDiscipline(disciplineId: string, targetId: 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;
}
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,6 +41,10 @@ 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'],
// 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'],
}; };
let key1: string | null = null; let key1: string | null = null;
@@ -63,6 +56,8 @@ 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;
} }
@@ -98,22 +93,17 @@ export function validateCast(actor: CastableActor, spell: SpellLike, opts: CastO
const manaSpent = Math.max(baseMana, Math.floor(opts.manaSpent ?? baseMana)); const manaSpent = Math.max(baseMana, Math.floor(opts.manaSpent ?? baseMana));
const bloodSpent = Math.max(0, Math.floor(opts.bloodSpent ?? Number(sys.bloodCost ?? 0))); 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)) { if (manaSpent > Number(a.mana?.value ?? 0)) {
// Allowed since the 2026-10-20 sync: the shortfall is borrowed (Wyczerpanie Many). // 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' }); warnings.push({ code: 'borrow-mana', message: 'Not enough mana - the shortfall will be borrowed (Wyczerpanie Many)', i18nKey: 'HBM.spellCast.borrowMana' });
} }
} }
if (opts.castAsPrayer) { // Prayer (any spell, sacred by default): Mana + 1 Zapał. A regular spell costs no Zapał.
if (1 > Number(a.zeal?.value ?? 0)) { 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' }); 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' });
}
}
if (mode === 'blood') { if (mode === 'blood') {
if (bloodSpent > Number(a.blood?.value ?? 0)) { if (bloodSpent > Number(a.blood?.value ?? 0)) {
errors.push({ code: 'no-blood', message: 'Not enough blood', i18nKey: 'HBM.spellCast.notEnoughBlood' }); errors.push({ code: 'no-blood', message: 'Not enough blood', i18nKey: 'HBM.spellCast.notEnoughBlood' });
@@ -206,7 +196,8 @@ export function validateCast(actor: CastableActor, spell: SpellLike, opts: CastO
// Witch symbol cap // Witch symbol cap
if (mode === 'witch' || opts.hekateMode === 'witch') { 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); const maxSymbols = Math.ceil(Number(a.magic?.value ?? 0) / 2);
if (required > maxSymbols) { if (required > maxSymbols) {
errors.push({ errors.push({
+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.1", "version": "1.5.2",
"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": {
+1 -1
View File
@@ -51,7 +51,7 @@
{{#if damage.targets}} {{#if damage.targets}}
<div class="hbm-card-targets"> <div class="hbm-card-targets">
{{#each damage.targets as |t|}} {{#each damage.targets as |t|}}
<button type="button" class="hbm-action" data-action="hbm-apply-damage" data-target-uuid="{{t.uuid}}" data-amount="{{../damage.total}}" data-type="{{../damage.type}}" data-ignores-armor="{{../damage.ignoresArmor}}" data-min-after-armor="{{../damage.minAfterArmor}}" data-profile="{{../damage.profile}}"> <button type="button" class="hbm-action" data-action="hbm-apply-damage" data-target-uuid="{{t.uuid}}" data-amount="{{../damage.total}}" data-type="{{../damage.type}}" data-ignores-armor="{{../damage.ignoresArmor}}" data-min-after-armor="{{../damage.minAfterArmor}}" data-profile="{{../damage.profile}}" data-fire="{{../damage.fire}}">
⚔ {{t.name}} ⚔ {{t.name}}
</button> </button>
{{/each}} {{/each}}
+1
View File
@@ -57,6 +57,7 @@
</label> </label>
<label>{{localize 'HBM.gear.strengthRequirement'}}: <input type="number" name="system.armor.strengthRequirement" value="{{system.armor.strengthRequirement}}" min="0"/></label> <label>{{localize 'HBM.gear.strengthRequirement'}}: <input type="number" name="system.armor.strengthRequirement" value="{{system.armor.strengthRequirement}}" min="0"/></label>
<label><input type="checkbox" name="system.armor.stealthDisadvantage" {{#if system.armor.stealthDisadvantage}}checked{{/if}}/> {{localize 'HBM.gear.stealthDisadvantage'}}</label> <label><input type="checkbox" name="system.armor.stealthDisadvantage" {{#if system.armor.stealthDisadvantage}}checked{{/if}}/> {{localize 'HBM.gear.stealthDisadvantage'}}</label>
<label title="{{localize 'HBM.gear.ignoresCastingPenaltyHint'}}"><input type="checkbox" name="system.armor.ignoresCastingPenalty" {{#if system.armor.ignoresCastingPenalty}}checked{{/if}}/> {{localize 'HBM.gear.ignoresCastingPenalty'}}</label>
</fieldset> </fieldset>
{{/if}} {{/if}}
+1
View File
@@ -148,6 +148,7 @@
<label>{{localize 'HBM.spell.damageBase'}}: <label>{{localize 'HBM.spell.damageBase'}}:
<input type="text" name="system.damageBase" value="{{system.damageBase}}" placeholder="np. 1d6+magicalAbilities" /> <input type="text" name="system.damageBase" value="{{system.damageBase}}" placeholder="np. 1d6+magicalAbilities" />
</label> </label>
<label><input type="checkbox" name="system.damageCategory" {{#if system.damageCategory}}checked{{/if}}/> {{localize 'HBM.spell.damageCategory'}}</label>
<label>{{localize 'HBM.spell.damageType'}}: <label>{{localize 'HBM.spell.damageType'}}:
<select name="system.damageType"> <select name="system.damageType">
{{#each @root.choices.damageTypes as |label key|}} {{#each @root.choices.damageTypes as |label key|}}
+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
@@ -0,0 +1,19 @@
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);
});
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import {
castingArmorPenalty, hardenedFireDamage, madnessThreshold, golemLimit, fallDamage, survivalTest,
resolveCastAsPrayer, zealRegenBlocked,
} from '../../src/logic/rules';
describe('2026-10-26 sync rules', () => {
it('armour casting penalties stack and Pancerz Wiary spares only armour', () => {
expect(castingArmorPenalty([{ armorType: 'medium' }])).toEqual({ dice: 1, mana: 0 });
expect(castingArmorPenalty([{ armorType: 'heavy' }, { armorType: 'shield' }])).toEqual({ dice: 4, mana: 2 });
expect(castingArmorPenalty([{ armorType: 'heavy' }, { armorType: 'shield' }], true)).toEqual({ dice: 1, mana: 0 });
expect(castingArmorPenalty([{ armorType: 'medium', ignoresCastingPenalty: true }])).toEqual({ dice: 0, mana: 0 });
});
it('Hartowany w Ogniu is flat -2, min 0', () => {
expect(hardenedFireDamage(5)).toBe(3);
expect(hardenedFireDamage(1)).toBe(0);
});
it('Próg Obłędu, golem limit, fall damage, Test Przeżycia', () => {
expect(madnessThreshold(4)).toBe(7);
expect(madnessThreshold(4, 'dwarf')).toBe(8);
expect(golemLimit(5)).toBe(2);
expect(golemLimit(1)).toBe(1);
expect(fallDamage(2)).toBe(0);
expect(fallDamage(6)).toBe(4);
expect(fallDamage(6, true)).toBe(2);
expect(fallDamage(100)).toBe(20);
expect(survivalTest(2)).toEqual({ threshold: 4, successes: 1 });
expect(survivalTest(6, 1)).toEqual({ threshold: 5, successes: 3 });
});
it('sacred spells default to prayer but can be cast as regular spells', () => {
const sacred = { system: { castingMode: 'sacred' } };
expect(resolveCastAsPrayer(sacred, {})).toBe(true);
expect(resolveCastAsPrayer(sacred, { castAsPrayer: false })).toBe(false);
expect(resolveCastAsPrayer({ system: { castingMode: 'standard' } }, {})).toBe(false);
});
it('paid extra actions block Zapał regen', () => {
expect(zealRegenBlocked(0, 0)).toBe(false);
expect(zealRegenBlocked(0, 1)).toBe(true);
});
});
+3 -1
View File
@@ -19,8 +19,10 @@ describe('spell-parser', () => {
expect(s.damageBase).toBe('magicalAbilities/2'); expect(s.damageBase).toBe('magicalAbilities/2');
expect(s.damageType).toBe('magical'); expect(s.damageType).toBe('magical');
expect(s.difficulty).toEqual({ threshold: 4, successes: 1 }); expect(s.difficulty).toEqual({ threshold: 4, successes: 1 });
expect(s.overcastOptions).toEqual([{ description: '+1 punkt Obrażeń (najwyżej dwa Poziomy Nadczarowania)', manaPerStep: 1 }]); expect(s.overcastOptions).toEqual([{ description: '+1 punkt Obrażeń', manaPerStep: 1 }]);
expect(s).not.toHaveProperty('circle'); expect(s).not.toHaveProperty('circle');
expect(s.damageCategory).toBe(true);
expect((docs.find((d) => d.id === 'crimson-bolt')!.system as any).damageCategory).toBe(true);
for (const d of docs) { for (const d of docs) {
const sys = d.system as any; const sys = d.system as any;