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
octoturge 582b189ce6 Merge pull request 'feat: weapon wound kinds (1.5.1)' (#7) from feat/blood-magic-runic-vault-answers into main 2026-10-22 21:33:15 +02:00
octoturge 5e6dc0698e Merge pull request 'feat: weapon wound kind (1.5.1)' (#6) from feat/weapon-wound-kind into feat/blood-magic-runic-vault-answers 2026-10-22 21:32:37 +02:00
octoturge 96b2935ff5 Merge pull request 'Blood magic + Runiczny Pancerz per vault answers (1.5.0)' (#5) from feat/blood-magic-runic-vault-answers into main 2026-10-22 21:32:28 +02:00
octoturge 8d5fd7338a Merge pull request 'Runic armour mana restore + NBP rates module (1.4.1)' (#4) from feat/runic-armor-restore-nbp-rates into main 2026-10-22 00:24:31 +02:00
octoturgeandClaude Sonnet 5.5 a5f2683d15 feat: weapon wound kinds as vault slashing/piercing/blunt list
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 00:23:15 +02:00
octoturgeandClaude Sonnet 5.5 23027cf1b3 feat: weapon wound kind (1.5.1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 00:05:57 +02:00
octoturgeandClaude Sonnet 5.5 7b1f6794ad feat: blood magic and Runiczny Pancerz per vault answers (1.5.0)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 17:12:21 +02:00
octoturgeandClaude Sonnet 5.5 e4dc1842d3 feat: restore Runiczny Pancerz with Mana; NBP rates module with latest-rate fallback
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 16:34:58 +02:00
octoturge 601f154b75 Merge pull request 'Vault sync 2026-10-20: rules, counters, migration 1.4.0' (#3) from sync/2026-10-20 into main 2026-10-20 15:37:37 +02:00
41 changed files with 1280 additions and 210 deletions
+63
View File
@@ -5,6 +5,69 @@ All notable changes to the **HbM RPG v3** Foundry VTT system.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] - 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
### Added
- Weapons have wound kinds (`system.weapon.woundKinds`: `slashing` sieczne / `piercing` kłute / `blunt` obuchowe, several allowed, empty = unspecified), editable on the gear sheet as a multi-select. Matches Foundry Sync Brief §18.
- Gear parser reads `wound` (string, comma list or YAML list; Polish or English words) from vault weapon notes. Unknown values are dropped; nothing is guessed.
- Packs rebuilt from the current vault: Sztylet piercing+slashing, Bicz slashing, Laska blunt, bows and Włócznia piercing; generic one-/two-handed entries list all three (pick the real type per weapon).
### Changed
- `applyDamage` takes `woundKinds` (array) instead of a single kind; passive Krew applies when any is slashing or piercing. Blunt (and unarmed) never gives Krew.
- Vault sync of 2026-10-22 needs no further logic changes: runic armour wording, Zakazana Osłona retaliation, Rozerwanie Run artifact destruction and free Immediate Action for Kradzież Życia / Złożenie w Ofierze were already covered in 1.5.0 (spell, talent and discipline text arrives through the rebuilt packs).
### Notes
- Callers of `applyDamage` still need to pass the equipped weapon's `woundKinds`.
- Kości Losu use is now an Immediate Action that counts toward the limit (vault `Zużywanie Ładunków`); the system has no Fate-dice action code yet.
## [1.5.0] - 2026-10-20
Vault answers (Foundry Sync Brief §17): blood magic and Runiczny Pancerz.
### Changed
- **Runiczny Pancerz:** one hit of more than 5 damage (after the Runiczny Pancerz step) disables 1 point; hits no longer accumulate (`runicCounter` is unused). Disabled points return at the end of the fight (`deleteCombat`), on Długi Odpoczynek, or when restored (Akcja Natychmiastowa; Amulet Osłony 2 Many per point). `destroyRunicPoints` (Rozerwanie Run) lowers the maximum permanently.
- **Shield-breaking spells** (Przebicie Eteru, Boska Włócznia, Fala Energii, Tajemniczy Strumień, Strzała Pożądania, Ostatni Sąd): every 2 damage disable a point and damage counts ×2 against Magiczna Osłona. Szkarłatny Pocisk ignores Runiczny Pancerz and Magiczna Osłona but not Pancerz; Prawdziwa Dezintegracja ignores Pancerz and destroys shields / disables all Runiczny Pancerz (`SPELL_DAMAGE_PROFILES`, chat-card `data-profile`).
- **Blood magic rewritten to the vault:** Samookaleczenie (Test Magia (Walka Wręcz) T=2, lose HP = Sukcesy, 1 Krew per HP, once per round; Większe: 2 Krew per HP), Kradzież Życia / Większa (1 Zapał, arms the next attack, Krew per HP actually lost), Złożenie w Ofierze, Wielka Ofiara (once per day), Wchłanianie Krwi (min. 1 Krew, once per round). Removed the invented ×3 self-harm penalty and `floor(damage/4)` life steal.
- Passive Krew: Szacunek do Życia 1 per 4 HP, Krwisty Pojedynkowicz 1 per 2 HP (cutting/piercing, either side; callers pass `woundKind`/`sourceActor` to `applyDamage`); Rytualista Krwi starts a fight with 4 Krew; leftover Krew heals 2 HP each at the end of the fight.
### Added
- Actor attributes `bloodUsed.*` (per round) and `greatSacrificeUsed` (per day).
## [1.4.1] - 2026-10-20
### Added
- Runiczny Pancerz can be restored with Mana: `game.hbm.runicArmor.rechargeRunicArmor(actor, points, { partial })` – 2 Many per point, capped at the armor's maximum (Amulet Osłony, Akcja). Degradation (every 5 absorbed damage) is unchanged.
### Changed
- Money converter: NBP rates come from `src/logic/nbp.ts` – one range request per currency (first table from 1 June of the campaign year), latest published rate for future years or when no table is found, 4 s timeout, offline fallback EUR = `TRADE.eurRate` (4.36), USD = 4.00.
## [1.4.0] - 2026-10-20
Vault sync ("Foundry Sync Brief 2026-10-20").
+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.
- **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.
- **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`).
- **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).
+20 -2
View File
@@ -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",
@@ -239,6 +244,13 @@
"physical": "Physical",
"magical": "Magical",
"environmental": "Environmental",
"woundKinds": "Wound kind",
"woundKindsHint": "Several allowed. Only slashing and piercing wounds grant talent Krew (Respect for Life, Crimson Duelist). Empty = unspecified.",
"wound": {
"slashing": "Slashing",
"piercing": "Piercing",
"blunt": "Blunt"
},
"properties": "Properties",
"damageReduction": "Damage Reduction",
"damageReductionBonus": "DR Bonus",
@@ -253,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",
@@ -356,8 +370,12 @@
},
"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",
"cannotBeUnraveled": "Cannot be unraveled",
"cannotBeUnraveled": "Cannot be dispelled",
"zealSpent": "Zeal spent",
"bloodSpent": "Blood spent",
"hekateMode": "Hekate mode (Sacred + Witch hybrid)",
@@ -416,7 +434,7 @@
}
},
"combat": {
"newRound": "New round - Mana reset",
"newRound": "New round - Immediate Action and Krew counters reset",
"newTurn": "New turn - Zeal +1",
"turnSkipped": "Turn skipped (condition)",
"deathSavePrompt": "Make a Death Saving Throw (Body, T:4, Y:1)"
+19 -1
View File
@@ -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",
@@ -259,6 +264,13 @@
"physical": "Fizyczne",
"magical": "Magiczne",
"environmental": "Środowiskowe",
"woundKinds": "Rodzaj rany",
"woundKindsHint": "Można wybrać kilka. Tylko rany sieczne i kłute dają Krew z talentów (Szacunek do Życia, Krwisty Pojedynkowicz). Puste = nieokreślony.",
"wound": {
"slashing": "Sieczne",
"piercing": "Kłute",
"blunt": "Obuchowe"
},
"properties": "Właściwości",
"damageReduction": "Redukcja Obrażeń",
"damageReductionBonus": "Premia do Redukcji",
@@ -273,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ść",
@@ -376,6 +390,10 @@
},
"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",
"cannotBeUnraveled": "Nie da się rozpleść",
"cast": "Rzuć",
@@ -419,7 +437,7 @@
}
},
"combat": {
"newRound": "Nowa runda - Mana zresetowana",
"newRound": "Nowa runda - liczniki Akcji Natychmiastowych i Krwi zresetowane",
"newTurn": "Nowa tura - Zapał +1",
"turnSkipped": "Tura pominięta (stan)",
"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",
"version": "1.4.0",
"version": "1.5.2",
"description": "Foundry VTT system for Homebrew Magic: Role Playing Game v3",
"private": true,
"type": "module",
+32 -1
View File
@@ -38,6 +38,27 @@ export const parseGear: ParserFn = async (ctx: ParserContext): Promise<ParsedDoc
return docs;
};
type WoundKind = 'slashing' | 'piercing' | 'blunt';
/**
* Vault `wound` frontmatter → woundKinds. Accepts one value, a comma list ("piercing, slashing"),
* or a YAML list (flattened to a comma list by extractMarkdown). Unknown values are dropped,
* absent stays empty – never guessed.
*/
export function parseWoundKinds(raw: string | undefined): WoundKind[] {
const map: Record<string, WoundKind> = {
slashing: 'slashing', sieczne: 'slashing', sieczna: 'slashing',
piercing: 'piercing', kłute: 'piercing', kłuta: 'piercing', kluta: 'piercing', kłujące: 'piercing',
blunt: 'blunt', obuchowe: 'blunt', obuchowa: 'blunt',
};
const out: WoundKind[] = [];
for (const part of (raw ?? '').split(',')) {
const kind = map[part.trim().toLowerCase()];
if (kind && !out.includes(kind)) out.push(kind);
}
return out;
}
function extractMarkdown(raw: string): { frontmatter: Record<string, string>; content: string } {
const fm: Record<string, string> = {};
let content = raw;
@@ -48,9 +69,17 @@ function extractMarkdown(raw: string): { frontmatter: Record<string, string>; co
const fmText = raw.slice(3, endIdx).trim();
content = raw.slice(endIdx + 3).trim();
const lines = fmText.split('\n');
const lines = fmText.split(/\r?\n/);
let listKey = '';
for (const line of lines) {
// "key:" followed by " - item" lines → comma-joined value.
const item = line.match(/^\s+-\s+(.*)$/);
if (item && listKey) {
fm[listKey] = fm[listKey] ? `${fm[listKey]}, ${item[1].trim()}` : item[1].trim();
continue;
}
const colonIdx = line.indexOf(':');
listKey = colonIdx > -1 && !line.slice(colonIdx + 1).trim() ? line.slice(0, colonIdx).trim() : '';
if (colonIdx > -1) {
const key = line.slice(0, colonIdx).trim();
let val = line.slice(colonIdx + 1).trim();
@@ -91,6 +120,7 @@ function buildGearDoc(name: string, fm: Record<string, string>, content: string,
weapon: {
damage: '',
damageType: 'physical',
woundKinds: [],
properties: [],
},
armor: {
@@ -105,6 +135,7 @@ function buildGearDoc(name: string, fm: Record<string, string>, content: string,
if (category === 'weapon') {
system.weapon.damage = fm.damage || fm.obrazenia || '';
system.weapon.woundKinds = parseWoundKinds(fm.wound || fm.rana);
if (fm.traits || fm.cechy) {
system.weapon.properties = parseList(fm.traits || fm.cechy);
}
+4 -2
View File
@@ -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,8 @@ const SpellNoteSchema = z.object({
targets: z.coerce.string().default(''),
duration: z.coerce.string().default(''),
damageBase: z.coerce.string().default(''),
category: 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 +94,8 @@ function toSystem(n: SpellNote): Record<string, unknown> {
duration: n.duration,
areaOfEffect: deriveAreaOfEffect(n.targets),
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',
ignoresArmor: n.ignoresArmor,
statusEffects: n.statusEffects,
@@ -105,7 +108,6 @@ function toSystem(n: SpellNote): Record<string, unknown> {
nonCombatOnly: n.nonCombatOnly,
overcastOptions: n.overcast.map((description) => ({ description, manaPerStep: 1 })),
triggers: n.triggers,
complexityLevel: n.complexityLevel,
};
}
+6 -1
View File
@@ -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',
@@ -192,6 +192,11 @@ export type GearCategory = (typeof GEAR_CATEGORIES)[number];
export const DAMAGE_TYPES = ['physical', 'magical', 'environmental'] as const;
export type DamageType = (typeof DAMAGE_TYPES)[number];
/** Weapon wound kinds (sieczne / kłute / obuchowe). A weapon may have several; none = unspecified.
* Only slashing and piercing wounds feed the passive Krew talents. */
export const WOUND_KINDS = ['slashing', 'piercing', 'blunt'] as const;
export type WoundKind = (typeof WOUND_KINDS)[number];
/** Armor types */
export const ARMOR_TYPES = ['light', 'medium', 'heavy', 'shield'] as const;
export type ArmorType = (typeof ARMOR_TYPES)[number];
+16 -2
View File
@@ -78,9 +78,20 @@ export class CharacterData extends foundry.abstract.TypeDataModel {
breathersTaken: makeIntField(0, { min: 0 }),
// Akcje Natychmiastowe used in the current round (1 free, Zapał buys more).
immediateActionsUsed: makeIntField(0, { min: 0 }),
// Insanity points (Klątwa Otchłani VIII). Each Abyss exposure may add 1; rolls
// mental-condition table when reaching thresholds.
// Blood-magic actions used this round (Samookaleczenie, Kradzież Życia, Złożenie w Ofierze, Wchłanianie Krwi).
bloodUsed: new f.SchemaField({
selfHarm: makeIntField(0, { min: 0 }),
lifeSteal: makeIntField(0, { min: 0 }),
sacrifice: makeIntField(0, { min: 0 }),
absorb: makeIntField(0, { min: 0 }),
}),
// Wielka Ofiara used today (once per day; reset by Długi Odpoczynek).
greatSacrificeUsed: makeIntField(0, { min: 0, max: 1 }),
// 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 }),
@@ -152,7 +163,10 @@ export class CharacterData extends foundry.abstract.TypeDataModel {
manaBorrowed: number;
breathersTaken: number;
immediateActionsUsed: number;
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 };
+5 -1
View File
@@ -6,7 +6,7 @@ import {
makeArrayField,
fields,
} from './fields';
import { GEAR_CATEGORIES, DAMAGE_TYPES, ARMOR_TYPES, RARITIES } from '../constants';
import { GEAR_CATEGORIES, DAMAGE_TYPES, ARMOR_TYPES, RARITIES, WOUND_KINDS } from '../constants';
/**
* Unified Gear item - discriminated by `category`.
@@ -39,6 +39,8 @@ export class GearData extends foundry.abstract.TypeDataModel {
choices: DAMAGE_TYPES as unknown as readonly string[],
}),
properties: makeArrayField(new f.StringField({ blank: false })),
// Rodzaj rany: slashing (sieczne) / piercing (kłute) / blunt (obuchowe); several allowed, empty = unspecified.
woundKinds: makeArrayField(new f.StringField({ blank: false, choices: WOUND_KINDS as unknown as string[] })),
}),
armor: new f.SchemaField({
@@ -51,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),
}),
};
}
+3 -1
View File
@@ -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.
};
}
}
+18 -15
View File
@@ -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(`
<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>
</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) {
const max = a.blood.value;
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}"/>
</div>`);
}
if (mode !== 'sacred') {
fields.push(`
<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>`);
}
if (showHekate) {
fields.push(`
<div class="form-group">
@@ -160,10 +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();
+2
View File
@@ -30,6 +30,7 @@ import { castSpell } from './logic/spell-cast';
import * as bloodMagic from './logic/blood-magic';
import * as abyssMagic from './logic/abyss-magic';
import * as brewing from './logic/brewing';
import * as runicArmor from './logic/runic-armor';
import { rest } from './logic/rest';
import * as trade from './logic/trade';
@@ -137,6 +138,7 @@ Hooks.once('ready', () => {
bloodMagic,
abyssMagic,
brewing,
runicArmor,
rest,
trade,
};
+25 -11
View File
@@ -12,12 +12,12 @@
* when present; otherwise falls back to a chat
* prompt for the GM).
* - rollMistrzLosuPenalty(actor) - d100 penalty roll (Klątwa Otchłani VII).
* - addInsanity(actor, n) - increments actor.system.attributes.insanity;
* when crossing a threshold, fires a hook for
* the GM to apply a mental condition.
* - addInsanity(actor, n) - adds Punkty Obłędu; at Próg Obłędu resets them,
* rolls the illness k100 and fires `hbm.mentalIllness`.
*/
import type { CastableActor } from './spell-cast';
import { madnessThreshold } from './rules';
export type AbyssDiscipline = 'aspects' | 'primal';
@@ -40,19 +40,33 @@ export function dispatchAbyssCast(spell: SpellLikeForAbyss): AbyssDiscipline {
return 'aspects';
}
/** Insanity thresholds (Klątwa Otchłani VIII). Crossing any triggers a mental-condition roll. */
const INSANITY_THRESHOLDS = [3, 6, 10, 15] as const;
/**
* Punkty Obłędu (Choroby Psychiczne). On reaching Próg Obłędu (Dusza + 3, Krasnolud +1) the points reset
* to 0 (the excess is lost) and the character gains one illness: k100 against the 50-illness table in the
* vault (`rules/02. Klątwa Otchłani/Rozdział IV - Choroby Psychiczne.md`), re-rolling duplicates. The table
* itself is not copied here, so the roll is posted to chat for the GM and `hbm.mentalIllness` is fired.
*/
export async function addInsanity(actor: CastableActor, n: number): Promise<{ before: number; after: number; thresholdsCrossed: number[] }> {
if (n <= 0) return { before: 0, after: 0, thresholdsCrossed: [] };
const before = ((actor.system.attributes as any).insanity ?? 0) as number;
const attrs = actor.system.attributes as any;
const before = (attrs.insanity ?? 0) as number;
const after = before + n;
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 });
const crossed = INSANITY_THRESHOLDS.filter((t) => before < t && after >= t);
if (crossed.length > 0) {
Hooks.callAll('hbm.insanityThreshold', actor, crossed, { before, 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. */
+222 -50
View File
@@ -1,35 +1,62 @@
/**
* Magia Krwi - Blood Magic logic (Arcanum Sanguinis Ch. III).
* Magia Krwi - Blood Magic logic (Arcanum Sanguinis).
*
* Three primitives:
* - spendBlood(actor, n) : deduct n from blood pool, fail if insufficient.
* - selfHarm(actor, hp) : convert hp → blood at 2:1 ratio (2 HP → 1 Blood).
* - lifeStealOnDamage(...) : when actor inflicts damage with a blood spell,
* restore blood = floor(damage / 4) (capped at max).
* Krew sources and sinks follow the vault (Foundry Sync Brief §17):
* - selfHarm Samookaleczenie (Akcja) / Większe Samookaleczenie (Akcja Natychmiastowa)
* - useLifeSteal / resolveLifeSteal Kradzież Życia / Większa Kradzież Życia (1 Zapał)
* - sacrifice / greatSacrifice Złożenie w Ofierze / Wielka Ofiara (1 Zapał)
* - absorbBlood Wchłanianie Krwi / Większe Wchłanianie Krwi
* - gainKrewFromWeaponDamage passive gain (Szacunek do Życia, Krwisty Pojedynkowicz)
* - startOfFightKrew / endOfFightKrew Rytualista Krwi start pool, leftover Krew heals
*
* Future hook: `Szacunek do Życia` talent triples self-harm cost (6 HP → 1 Blood).
*
* The cast pipeline (logic/spell-cast.ts) already deducts `bloodCost` directly;
* this module is the reusable API surface for UI dialogs and the damage hook.
* Per-round limits live in `attributes.bloodUsed` (reset every round by logic/combat.ts);
* Wielka Ofiara is once per day (`attributes.greatSacrificeUsed`, reset by Długi Odpoczynek).
* The cast pipeline (logic/spell-cast.ts) deducts `bloodCost` directly.
*/
import { getMagicPowerEntry } from '../constants';
import type { CastableActor } from './spell-cast';
import { bloodAbsorbHp, bloodToZeal } from './rules';
import {
bloodAbsorbHp,
bloodToZeal,
greatSacrificeResult,
krewFromLifeSteal,
krewFromSelfHarm,
krewFromWeaponHpLoss,
sacrificeResult,
startingKrew,
type KrewTalent,
} from './rules';
const SELF_HARM_RATIO = 2; // HP per 1 Blood
const RESPECT_FOR_LIFE_PENALTY = 3; // multiplier when talent present
const LIFE_STEAL_DIVISOR = 4; // damage / N → blood restored
const SELF_HARM_THRESHOLD = 2;
const FLAG_SCOPE = 'hbm-rpg-v3';
interface ActorWithItems extends CastableActor {
items?: Iterable<{ type: string; system?: { slug?: string } }>;
}
function hasRespectForLife(actor: ActorWithItems): boolean {
if (!actor.items) return false;
for (const it of actor.items) {
export type BloodAction = 'selfHarm' | 'lifeSteal' | 'sacrifice' | 'absorb';
/** Talent that decides passive Krew gain / starting pool, if any. */
export function krewTalent(actor: CastableActor): KrewTalent | null {
const items = (actor as ActorWithItems).items;
if (!items) return null;
let found: KrewTalent | null = null;
for (const it of items) {
if (it.type !== 'talent') continue;
const slug = (it as any).system?.slug ?? (it as any).flags?.['hbm-rpg-v3']?.slug;
if (slug === 'respect-for-life' || slug === 'szacunek-do-zycia') return true;
const slug = (it as any).system?.slug ?? (it as any).flags?.[FLAG_SCOPE]?.slug;
if (slug === 'rytualista-krwi') return 'rytualista-krwi';
if (slug === 'krwisty-pojedynkowicz') found = 'krwisty-pojedynkowicz';
else if ((slug === 'respect-for-life' || slug === 'szacunek-do-zycia') && !found) found = 'respect-for-life';
}
return found;
}
function hasTalent(actor: CastableActor, ...slugs: string[]): boolean {
const items = (actor as ActorWithItems).items;
if (!items) return false;
for (const it of items) {
if (it.type === 'talent' && slugs.includes((it as any).system?.slug ?? (it as any).flags?.[FLAG_SCOPE]?.slug)) return true;
}
return false;
}
@@ -53,33 +80,155 @@ export async function spendBlood(actor: CastableActor, amount: number): Promise<
return { ok: true, spent: amount, remaining: blood.value - amount };
}
/**
* Self-Harm: convert health into blood. Returns blood gained.
* Default ratio 2 HP → 1 Blood; with `Szacunek do Życia` talent → 6 HP → 1 Blood.
*/
export async function selfHarm(actor: CastableActor, hpSpent: number): Promise<{ ok: boolean; bloodGained: number; reason?: string }> {
if (hpSpent <= 0) return { ok: true, bloodGained: 0 };
const a = actor.system.attributes;
const blood = a.blood;
if (!blood) return { ok: false, bloodGained: 0, reason: 'no-blood-pool' };
const health = (a as any).health as { value: number; max: number } | undefined;
if (!health || health.value < hpSpent) {
return { ok: false, bloodGained: 0, reason: 'insufficient-health' };
function used(actor: CastableActor, action: BloodAction): number {
return Number((actor.system.attributes as any).bloodUsed?.[action]) || 0;
}
const ratio = hasRespectForLife(actor as ActorWithItems) ? SELF_HARM_RATIO * RESPECT_FOR_LIFE_PENALTY : SELF_HARM_RATIO;
const gained = Math.floor(hpSpent / ratio);
if (gained <= 0) return { ok: false, bloodGained: 0, reason: 'ratio-too-low' };
const newBlood = Math.min(blood.max, blood.value + gained);
function bump(action: BloodAction, actor: CastableActor): Record<string, number> {
return { [`system.attributes.bloodUsed.${action}`]: used(actor, action) + 1 };
}
function addKrew(blood: { value: number; max: number }, amount: number): number {
return Math.max(0, Math.min(blood.max, blood.value + amount) - blood.value);
}
/** Magia dice + Walka Wręcz skill, threshold `threshold` – returns the Sukcesy. */
export async function rollMagicMelee(actor: CastableActor, threshold: number): Promise<number> {
const attrs = actor.system.attributes as any;
const magic = attrs.magic ?? {};
const dice = typeof magic.dicePool === 'number'
? magic.dicePool
: getMagicPowerEntry(magic.actual ?? magic.value ?? 0).dicePool;
const skill = (actor.system.skills as any)?.melee?.value ?? 0;
const { HbmTSRoll } = await import('../dice/ts-roll'); // lazy: needs Foundry's Roll at runtime only
const roll = HbmTSRoll.fromParams({ pool: dice + skill, threshold, required: 1, flavor: 'Magia (Walka Wręcz)' });
await roll.evaluate();
await roll.toMessage({ flavor: 'Magia (Walka Wręcz)' });
return roll.ts?.successes ?? 0;
}
type SuccessRoller = (actor: CastableActor, threshold: number) => Promise<number>;
export interface BloodActionResult {
ok: boolean;
successes?: number;
hpLost?: number;
hpGained?: number;
krewGained: number;
reason?: string;
}
const fail = (reason: string): BloodActionResult => ({ ok: false, krewGained: 0, reason });
/**
* Samookaleczenie (Akcja, once per round): Test Magia (Walka Wręcz) T=2; lose HP = Sukcesy,
* gain 1 Krew per HP. Większe Samookaleczenie (Krwisty Pojedynkowicz, Akcja Natychmiastowa): 2 Krew per HP.
*/
export async function selfHarm(
actor: CastableActor,
opts: { greater?: boolean; roller?: SuccessRoller } = {},
): Promise<BloodActionResult> {
const a = actor.system.attributes as any;
if (!a.blood) return fail('no-blood-pool');
if (opts.greater && !hasTalent(actor, 'wieksze-samookaleczenie', 'krwisty-pojedynkowicz')) return fail('missing-talent');
if (used(actor, 'selfHarm') >= 1) return fail('already-used');
const successes = await (opts.roller ?? rollMagicMelee)(actor, SELF_HARM_THRESHOLD);
const hpLost = Math.min(successes, Math.max(0, a.health?.value ?? 0));
const krewGained = addKrew(a.blood, krewFromSelfHarm(hpLost, !!opts.greater));
await actor.update({
'system.attributes.health.value': health.value - hpSpent,
'system.attributes.blood.value': newBlood,
'system.attributes.health.value': (a.health?.value ?? 0) - hpLost,
'system.attributes.blood.value': a.blood.value + krewGained,
...bump('selfHarm', actor),
});
return { ok: true, bloodGained: newBlood - blood.value };
return { ok: true, successes, hpLost, krewGained };
}
async function spendZeal(actor: CastableActor, amount: number): Promise<boolean> {
const zeal = (actor.system.attributes as any).zeal;
if (!zeal || zeal.value < amount) return false;
await actor.update({ 'system.attributes.zeal.value': zeal.value - amount });
return true;
}
/**
* Wchłanianie Krwi: spend Krew to heal Żywotność – 2 HP per Krew (Akcja, once per round);
* Większe Wchłanianie Krwi: 3 HP per Krew (Akcja Natychmiastowa), or 1 Zapał per 5 Krew.
* Kradzież Życia: 1 Zapał, free Akcja Natychmiastowa (outside the normal limit), once per round
* (Większa Kradzież Życia: twice per round, two steps easier). Arms the next attack; call
* `resolveLifeSteal` with the Żywotność the target actually lost.
*/
export async function useLifeSteal(actor: CastableActor, opts: { greater?: boolean } = {}): Promise<{ ok: boolean; stepsEasier: number; reason?: string }> {
const limit = opts.greater ? 2 : 1;
if (!(actor.system.attributes as any).blood) return { ok: false, stepsEasier: 0, reason: 'no-blood-pool' };
if (opts.greater && !hasTalent(actor, 'wieksza-kradziez-zycia')) return { ok: false, stepsEasier: 0, reason: 'missing-talent' };
if (used(actor, 'lifeSteal') >= limit) return { ok: false, stepsEasier: 0, reason: 'already-used' };
if (!(await spendZeal(actor, 1))) return { ok: false, stepsEasier: 0, reason: 'insufficient-zeal' };
const stepsEasier = opts.greater ? 2 : 1;
await actor.update(bump('lifeSteal', actor));
await (actor as any).setFlag?.(FLAG_SCOPE, 'lifeSteal', { stepsEasier, greater: !!opts.greater });
return { ok: true, stepsEasier };
}
/** Kradzież Życia payout: 1 Krew (2 greater) per Żywotność the target actually lost. */
export async function resolveLifeSteal(actor: CastableActor, hpActuallyLost: number): Promise<number> {
const pending = (actor as any).getFlag?.(FLAG_SCOPE, 'lifeSteal') as { greater?: boolean } | undefined;
const blood = (actor.system.attributes as any).blood;
if (!pending || !blood) return 0;
const gained = addKrew(blood, krewFromLifeSteal(hpActuallyLost, !!pending.greater));
if (gained > 0) await actor.update({ 'system.attributes.blood.value': blood.value + gained });
await (actor as any).unsetFlag?.(FLAG_SCOPE, 'lifeSteal');
return gained;
}
/**
* Złożenie w Ofierze: 1 Zapał, free Akcja Natychmiastowa, once per round. Triggers when your attack
* kills a target with a slashing / piercing wound within 1 m. `successes` are the Sukcesy of the
* killing attack's Test: heal Sukcesy + Magia, gain Krew = Sukcesy.
*/
export async function sacrifice(actor: CastableActor, successes: number): Promise<BloodActionResult> {
const a = actor.system.attributes as any;
if (!a.blood) return fail('no-blood-pool');
if (used(actor, 'sacrifice') >= 1) return fail('already-used');
if (hasTalent(actor, 'wielka-ofiara', 'rytualista-krwi')) return fail('replaced-by-wielka-ofiara');
if (!(await spendZeal(actor, 1))) return fail('insufficient-zeal');
const res = sacrificeResult(successes, a.magic?.actual ?? a.magic?.value ?? 0);
const hpGained = Math.min(res.heal, Math.max(0, (a.health?.max ?? 0) - (a.health?.value ?? 0)));
const krewGained = addKrew(a.blood, res.krew);
await actor.update({
'system.attributes.health.value': (a.health?.value ?? 0) + hpGained,
'system.attributes.blood.value': a.blood.value + krewGained,
...bump('sacrifice', actor),
});
return { ok: true, successes, hpGained, krewGained };
}
/**
* Wielka Ofiara (Rytualista Krwi; replaces Złożenie w Ofierze): 1 Zapał, once per day, target within 1 m
* is being attacked. Sukcesy above the target's highest Atrybut → the target loses all Żywotność
* (returned as `targetHpLost`, the caller applies it); otherwise the mage heals Sukcesy.
*/
export async function greatSacrifice(
actor: CastableActor,
successes: number,
target: { highestAttribute: number; health: number },
): Promise<BloodActionResult & { kills?: boolean; targetHpLost?: number }> {
const a = actor.system.attributes as any;
if (!a.blood) return fail('no-blood-pool');
if (!hasTalent(actor, 'wielka-ofiara', 'rytualista-krwi')) return fail('missing-talent');
if (Number(a.greatSacrificeUsed) >= 1) return fail('already-used');
if (!(await spendZeal(actor, 1))) return fail('insufficient-zeal');
const res = greatSacrificeResult(successes, target.highestAttribute, target.health);
const hpGained = Math.min(res.heal, Math.max(0, (a.health?.max ?? 0) - (a.health?.value ?? 0)));
const krewGained = addKrew(a.blood, res.krew);
await actor.update({
'system.attributes.health.value': (a.health?.value ?? 0) + hpGained,
'system.attributes.blood.value': a.blood.value + krewGained,
'system.attributes.greatSacrificeUsed': 1,
});
return { ok: true, successes, hpGained, krewGained, kills: res.kills, targetHpLost: res.kills ? target.health : 0 };
}
/**
* Wchłanianie Krwi (Akcja, once per round, min. 1 Krew): 2 HP per Krew.
* Większe Wchłanianie Krwi (Akcja Natychmiastowa): 3 HP per Krew, or 5 Krew → 1 Zapał.
*/
export async function absorbBlood(
actor: CastableActor,
@@ -87,8 +236,9 @@ export async function absorbBlood(
opts: { greater?: boolean; asZeal?: boolean } = {},
): Promise<{ ok: boolean; hpGained: number; zealGained: number; reason?: string }> {
const a = actor.system.attributes as any;
if (bloodSpent <= 0) return { ok: true, hpGained: 0, zealGained: 0 };
if (bloodSpent < 1) return { ok: false, hpGained: 0, zealGained: 0, reason: 'minimum-one-blood' };
if (!a.blood || a.blood.value < bloodSpent) return { ok: false, hpGained: 0, zealGained: 0, reason: 'insufficient-blood' };
if (!opts.greater && used(actor, 'absorb') >= 1) return { ok: false, hpGained: 0, zealGained: 0, reason: 'already-used' };
const update: Record<string, number> = { 'system.attributes.blood.value': a.blood.value - bloodSpent };
let hpGained = 0;
let zealGained = 0;
@@ -99,18 +249,40 @@ export async function absorbBlood(
hpGained = Math.min(bloodAbsorbHp(bloodSpent, !!opts.greater), Math.max(0, (a.health?.max ?? 0) - (a.health?.value ?? 0)));
update['system.attributes.health.value'] = (a.health?.value ?? 0) + hpGained;
}
if (!opts.greater) Object.assign(update, bump('absorb', actor));
await actor.update(update);
return { ok: true, hpGained, zealGained };
}
/** Life Steal: invoked from the damage-application hook when the source is a blood spell. */
export async function lifeStealOnDamage(actor: CastableActor, damageDealt: number): Promise<number> {
if (damageDealt <= 0) return 0;
const blood = actor.system.attributes.blood;
/**
* Passive Krew (talents): 1 per 4 HP lost (Szacunek do Życia) / 1 per 2 HP (Krwisty Pojedynkowicz),
* by either side, only from slashing or piercing weapons. Call for the victim and the attacker.
*/
export async function gainKrewFromWeaponDamage(actor: CastableActor, hpLost: number): Promise<number> {
const blood = (actor.system.attributes as any)?.blood;
if (!blood) return 0;
const restore = Math.floor(damageDealt / LIFE_STEAL_DIVISOR);
if (restore <= 0) return 0;
const newBlood = Math.min(blood.max, blood.value + restore);
await actor.update({ 'system.attributes.blood.value': newBlood });
return newBlood - blood.value;
const gained = addKrew(blood, krewFromWeaponHpLoss(hpLost, krewTalent(actor)));
if (gained > 0) await actor.update({ 'system.attributes.blood.value': blood.value + gained });
return gained;
}
/** Start of a fight: Krew pool resets to 0 (Rytualista Krwi: 4) for blood-talent holders. */
export async function startOfFightKrew(actor: CastableActor): Promise<void> {
const talent = krewTalent(actor);
const blood = (actor.system.attributes as any)?.blood;
if (!talent || !blood) return;
const target = Math.min(blood.max, startingKrew(talent));
if (blood.value !== target) await actor.update({ 'system.attributes.blood.value': target });
}
/** End of a fight: leftover Krew vanishes and heals the mage as Wchłanianie Krwi (2 HP per Krew). */
export async function endOfFightKrew(actor: CastableActor): Promise<number> {
const a = actor.system.attributes as any;
if (!krewTalent(actor) || !a.blood || a.blood.value <= 0) return 0;
const hpGained = Math.min(bloodAbsorbHp(a.blood.value, false), Math.max(0, (a.health?.max ?? 0) - (a.health?.value ?? 0)));
await actor.update({
'system.attributes.blood.value': 0,
'system.attributes.health.value': (a.health?.value ?? 0) + hpGained,
});
return hpGained;
}
+11 -3
View File
@@ -9,6 +9,7 @@
*/
import { applyDamage } from './damage';
import type { SpellDamageProfile } from './rules';
import { CONDITIONS } from './conditions';
declare const Hooks: any;
@@ -71,13 +72,20 @@ async function onApplyDamage(btn: HTMLElement): Promise<void> {
return;
}
const ignoresArmor = btn.dataset.ignoresArmor === 'true';
let profile: SpellDamageProfile | null = null;
try { profile = btn.dataset.profile ? JSON.parse(btn.dataset.profile) : null; } catch { /* ignore */ }
await applyDamage(target, {
amount,
type: (btn.dataset.type as any) ?? 'magical',
ignoreMagicalArmor: ignoresArmor,
ignoreMagicalShield: ignoresArmor,
ignorePhysicalArmor: ignoresArmor,
// A spell-specific profile replaces the blanket "ignores all armour" flag.
ignoreMagicalArmor: profile ? !!profile.ignoreMagicalArmor : ignoresArmor,
ignoreMagicalShield: profile ? !!profile.ignoreMagicalShield : ignoresArmor,
ignorePhysicalArmor: profile ? !!profile.ignorePhysicalArmor : ignoresArmor,
runicArmorStep: profile?.runicArmorStep,
shieldMultiplier: profile?.shieldMultiplier,
destroyMagicalDefenses: profile?.destroyMagicalDefenses,
minDamageAfterArmor: btn.dataset.minAfterArmor === 'true',
fire: btn.dataset.fire === 'true',
} as any);
}
+33
View File
@@ -7,6 +7,8 @@
*/
import { applyDamage } from './damage';
import { startOfFightKrew, endOfFightKrew } from './blood-magic';
import { restoreAllRunicArmor } from './runic-armor';
function hasStatus(actor: any, id: string): boolean {
const effects = actor?.effects ?? [];
@@ -55,6 +57,15 @@ export function registerCombatHooks(): void {
if (actor?.system?.attributes?.immediateActionsUsed) {
await actor.update({ 'system.attributes.immediateActionsUsed': 0 });
}
const bu = actor?.system?.attributes?.bloodUsed;
if (bu && Object.values(bu).some((n) => Number(n) > 0)) {
await actor.update({
'system.attributes.bloodUsed.selfHarm': 0,
'system.attributes.bloodUsed.lifeSteal': 0,
'system.attributes.bloodUsed.sacrifice': 0,
'system.attributes.bloodUsed.absorb': 0,
});
}
}
ChatMessage.create({
content: `<em>${game.i18n.localize('HBM.combat.newRound')}</em>`,
@@ -62,6 +73,23 @@ export function registerCombatHooks(): void {
});
});
// Start of a fight: Krew pool resets (Rytualista Krwi starts with 4).
Hooks.on('combatStart', async (combat: Combat) => {
for (const c of (combat as any)?.combatants ?? []) {
if (c.actor?.type === 'character') await startOfFightKrew(c.actor);
}
});
// End of a fight: disabled Runiczny Pancerz returns, per-round counters clear, leftover Krew heals.
Hooks.on('deleteCombat', async (combat: Combat) => {
for (const c of (combat as any)?.combatants ?? []) {
const actor = c.actor;
if (!actor || actor.type !== 'character') continue;
await restoreAllRunicArmor(actor);
await endOfFightKrew(actor);
}
});
Hooks.on('combatTurn', async (combat: Combat) => {
// The previous combatant's turn just ended: expire their timed Stany.
const prevId = (combat as any).previous?.combatantId;
@@ -95,7 +123,12 @@ export function registerCombatHooks(): void {
}
// 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') {
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 regen = getZealRegen(actor);
const next = Math.min(sys.attributes.zeal.max, sys.attributes.zeal.value + regen);
+61 -15
View File
@@ -1,6 +1,6 @@
/**
* Damage application engine - flows through 4 layers:
* 1. Magical Armor (DR + every 5 dmg absorbed degrades value by 1 via runicCounter)
* 1. Runiczny Pancerz (DR; one hit > 5 disables 1 point until end of fight / restore)
* 2. Magical Shield (raw temp HP; cannot be healed; vanishes at 0)
* 3. Physical Armor (flat DR; optionally degrade `condition` by 1)
* 4. Health (the leftover hits HP)
@@ -14,7 +14,8 @@
*/
import { DamageType } from '../constants';
import { triggersStun, concentrationTest } from './rules';
import { triggersStun, concentrationTest, runicArmorPointsDisabled, hardenedFireDamage, actorHasTalent } from './rules';
import { gainKrewFromWeaponDamage } from './blood-magic';
export interface ApplyDamageOptions {
amount: number;
@@ -25,6 +26,21 @@ export interface ApplyDamageOptions {
damageArmor?: boolean;
/** Magiczny Pocisk: at least 1 damage gets through Pancerz to Żywotność. */
minDamageAfterArmor?: boolean;
/** Shield-breaker: every N (2 instead of 5) damage disable a Runiczny Pancerz point. */
runicArmorStep?: number;
/** Shield-breaker: damage counts ×N against Magiczna Osłona. */
shieldMultiplier?: number;
/** Prawdziwa Dezintegracja: disables all Runiczny Pancerz points and destroys Magiczna Osłona. */
destroyMagicalDefenses?: boolean;
/** Rozerwanie Run: permanently destroys N Runiczny Pancerz points (lowers its maximum). */
destroyRunicPoints?: number;
/** Wound kind of a weapon hit (gear data has none yet – callers pass it); drives passive Krew. */
/** Wound kinds of the attacking weapon; only slashing / piercing give passive Krew. */
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;
}
@@ -34,6 +50,12 @@ export interface DamageReport {
hpDamage: number;
shieldDropped: boolean;
oszolomionyApplied: boolean;
/** Runiczny Pancerz points disabled by this hit (restored at end of fight). */
runicArmorDisabled?: number;
/** Runiczny Pancerz points destroyed permanently by this hit. */
runicArmorDestroyed?: number;
/** Krew gained by the victim / attacker through weapon-damage talents. */
krewGained?: { victim: number; source: number };
/** Dusza (Determinacja) 4:S for a concentrating target; absent when no Żywotność was lost. */
concentrationTest?: { threshold: number; successes: number };
}
@@ -48,7 +70,8 @@ interface ActorLike {
}
export async function applyDamage(actor: ActorLike, opts: ApplyDamageOptions): Promise<DamageReport> {
const total = Math.max(0, Math.floor(opts.amount));
let total = Math.max(0, Math.floor(opts.amount));
if (opts.fire && actorHasTalent(actor as any, 'Hartowany w Ogniu')) total = hardenedFireDamage(total);
const a = actor.system.attributes;
const updates: Record<string, unknown> = {};
const report: DamageReport = {
@@ -61,30 +84,45 @@ export async function applyDamage(actor: ActorLike, opts: ApplyDamageOptions): P
let remaining = total;
// Layer 1: Magical Armor - DR-based; runicCounter accumulates incoming damage.
// Layer 1: Runiczny Pancerz - flat DR. ONE hit disables points (never accumulated across hits):
// > 5 damage after the step → 1 point; shield-breakers: every 2 damage → 1 point. Disabled
// points come back at the end of the fight / when restored (see logic/runic-armor.ts).
if (a.magicalArmor && opts.destroyRunicPoints) {
// Rozerwanie Run: permanent – lowers the maximum, cannot be restored.
const value = Math.max(0, Number(a.magicalArmor.value) || 0);
const max = Math.max(0, Number(a.magicalArmor.max) || 0);
const lost = Math.min(max, opts.destroyRunicPoints);
updates['system.attributes.magicalArmor.max'] = max - lost;
updates['system.attributes.magicalArmor.value'] = Math.min(value, max - lost);
report.runicArmorDestroyed = lost;
}
if (!opts.ignoreMagicalArmor && a.magicalArmor) {
const dr = Math.max(0, Number(a.magicalArmor.value) || 0);
const absorbed = Math.min(dr, remaining);
if (absorbed > 0) {
const incomingDmg = remaining;
const incoming = remaining;
report.absorbed.magicalArmor = absorbed;
remaining -= absorbed;
const newCounter = (Number(a.magicalArmor.runicCounter) || 0) + incomingDmg;
const decrements = Math.floor(newCounter / 5);
const newValue = Math.max(0, dr - decrements);
updates['system.attributes.magicalArmor.runicCounter'] = newCounter % 5;
const disabled = opts.destroyMagicalDefenses
? dr
: runicArmorPointsDisabled(opts.runicArmorStep ? incoming : remaining, opts.runicArmorStep);
const current = (updates['system.attributes.magicalArmor.value'] as number | undefined) ?? dr;
const newValue = Math.max(0, Math.min(current, dr) - disabled);
if (disabled > 0) report.runicArmorDisabled = Math.min(dr, disabled);
updates['system.attributes.magicalArmor.value'] = newValue;
}
}
// Layer 2: Magical Shield - pure temp HP (raw subtraction).
// Layer 2: Magiczna Osłona - pure temp HP; shield-breakers count damage ×N against it.
if (remaining > 0 && !opts.ignoreMagicalShield && a.magicalShield) {
const shield = Math.max(0, Number(a.magicalShield.value) || 0);
const taken = Math.min(shield, remaining);
if (taken > 0) {
report.absorbed.magicalShield = taken;
remaining -= taken;
const newShield = shield - taken;
const mult = Math.max(1, opts.shieldMultiplier ?? 1);
const effective = Math.min(shield, remaining * mult);
if (effective > 0) {
const consumed = Math.ceil(effective / mult);
report.absorbed.magicalShield = consumed;
remaining -= consumed;
const newShield = opts.destroyMagicalDefenses ? 0 : shield - effective;
updates['system.attributes.magicalShield.value'] = newShield;
if (newShield === 0) report.shieldDropped = true;
}
@@ -127,6 +165,14 @@ export async function applyDamage(actor: ActorLike, opts: ApplyDamageOptions): P
if (Object.keys(updates).length > 0) await actor.update(updates);
// Passive Krew from slashing / piercing weapons (talents): victim and attacker side.
if (report.hpDamage > 0 && (opts.woundKinds ?? []).some((k) => k === 'slashing' || k === 'piercing')) {
report.krewGained = {
victim: await gainKrewFromWeaponDamage(actor as any, report.hpDamage),
source: opts.sourceActor ? await gainKrewFromWeaponDamage(opts.sourceActor, report.hpDamage) : 0,
};
}
// Oszołomiony: a single hit that costs ≥ half max Żywotność after all reductions.
if (triggersStun(report.hpDamage, Number(a.health?.max) || 0) && actor.toggleStatusEffect) {
const already = actor.effects?.find((e: any) => e.statuses?.has?.('oszolomiony'));
+53
View File
@@ -0,0 +1,53 @@
/**
* NBP (Narodowy Bank Polski) table A mid rates for the money converter.
* Campaign year → first published rate on/after 1 June of that year; a year in the
* future (or no table found / request failed) → latest published rate.
*/
import { TRADE } from './rules';
export interface NbpRate {
rate: number;
/** Effective date (YYYY-MM-DD) of the table the rate comes from. */
date: string;
}
const NBP_FIRST_YEAR = 2002;
const NBP_TIMEOUT_MS = 4000;
/** Offline fallbacks when NBP is unreachable. */
export const FALLBACK_RATES: Record<string, number> = { eur: TRADE.eurRate, usd: 4.0 };
type FetchLike = (url: string, init?: { signal?: AbortSignal }) => Promise<{ ok: boolean; json(): Promise<any> }>;
async function getRates(url: string, fetchImpl: FetchLike): Promise<NbpRate | null> {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), NBP_TIMEOUT_MS);
try {
const res = await fetchImpl(url, { signal: ctrl.signal });
if (!res.ok) return null;
const first = (await res.json())?.rates?.[0];
return typeof first?.mid === 'number' ? { rate: first.mid, date: String(first.effectiveDate ?? '') } : null;
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
/** Fetch the rate for `currency` (ISO code, any case) valid for the given campaign year. */
export async function fetchNbpRate(
currency: string,
year: number,
now: Date = new Date(),
fetchImpl: FetchLike = (u, i) => fetch(u, i),
): Promise<NbpRate | null> {
const code = currency.toLowerCase();
const base = `https://api.nbp.pl/api/exchangerates/rates/a/${code}`;
if (year >= NBP_FIRST_YEAR && year <= now.getFullYear()) {
// A two-week window always contains a working day; take the first table in it.
const hit = await getRates(`${base}/${year}-06-01/${year}-06-14/?format=json`, fetchImpl);
if (hit) return hit;
}
return getRates(`${base}/?format=json`, fetchImpl);
}
+7 -2
View File
@@ -141,8 +141,13 @@ export async function rest(actor: CastableActor, kind: RestKind): Promise<RestRe
update['system.attributes.elixirTolerance'] = Math.max(0, toleranceBefore - 1);
toleranceRecovered = 1;
}
if (a.magicalArmor?.runicCounter && a.magicalArmor.runicCounter > 0) {
update['system.attributes.magicalArmor.runicCounter'] = 0;
// Disabled Runiczny Pancerz points come back (only Rozerwanie Run is permanent).
if (a.magicalArmor && a.magicalArmor.value < a.magicalArmor.max) {
update['system.attributes.magicalArmor.value'] = a.magicalArmor.max;
}
if (a.greatSacrificeUsed) update['system.attributes.greatSacrificeUsed'] = 0;
if (a.bloodUsed) {
for (const k of ['selfHarm', 'lifeSteal', 'sacrifice', 'absorb']) update[`system.attributes.bloodUsed.${k}`] = 0;
}
}
+196 -2
View File
@@ -4,8 +4,8 @@
*
* Rounding convention: everything fractional rounds UP, except
* - new-spell mana cost (nearest, min 1),
* - Nadczarowanie bonus from level 3 (halved, DOWN, min 1),
* - fire damage with Hartowany w Ogniu (halved, DOWN).
* - Nadczarowanie bonus from level 3 (halved, DOWN, min 1).
* (Hartowany w Ogniu is a flat -2 per fire hit since the 2026-10-26 sync, no rounding.)
*/
/** Odetchnięcie: at most twice between two Długie Odpoczynki. */
@@ -133,6 +133,195 @@ export function bloodToZeal(blood: number): number {
return Math.floor(Math.max(0, blood) / 5);
}
// ---------------------------------------------------------------------------
// 2026-10-26 sync
// ---------------------------------------------------------------------------
export interface WornArmorPiece {
/** `armor.armorType` of an equipped gear item. */
armorType: string;
/** Special-case item that never hampers casting (Średnia zbroja kultysty, Napierśnik Ninurty). */
ignoresCastingPenalty?: boolean;
}
export interface CastingArmorPenalty {
/** Dice removed from the Magia test (positive number). */
dice: number;
/** Extra Mana per spell, also when overcasting. */
mana: number;
}
/**
* Zbroja a rzucanie zaklęć: Średni -1 die; Ciężki -3 dice and +2 Mana; Tarcza -1 die (stacks).
* Pancerz Wiary removes the armour penalties (dice AND Mana) but never the shield penalty.
* Runiczny Pancerz and Magiczne Osłony are not gear and never carry a penalty.
*/
export function castingArmorPenalty(worn: WornArmorPiece[], faithArmor = false): CastingArmorPenalty {
let dice = 0;
let mana = 0;
for (const piece of worn) {
if (piece.ignoresCastingPenalty) continue;
if (piece.armorType === 'shield') dice += 1;
else if (faithArmor) continue;
else if (piece.armorType === 'medium') dice += 1;
else if (piece.armorType === 'heavy') { dice += 3; mana += 2; }
}
return { dice, mana };
}
/** Does the actor own a talent item with this id / name? (case-insensitive) */
export function actorHasTalent(actor: unknown, talentId: string): boolean {
const id = talentId.toLowerCase();
const items = (actor as any)?.items as Iterable<any> | undefined;
if (!items) return false;
for (const it of items) {
if (it.type !== 'talent') continue;
const t = String(it.system?.talentId ?? it.system?.id ?? it.name ?? '').toLowerCase();
if (t === id) return true;
}
return false;
}
/** Prayer or regular spell? Sacred spells default to a prayer; everything else defaults to a regular spell. */
export function resolveCastAsPrayer(
spell: { system: { castingMode?: string } },
opts: { castAsPrayer?: boolean; hekateMode?: string },
): boolean {
if (opts.castAsPrayer != null) return opts.castAsPrayer;
return (spell.system.castingMode ?? 'standard') === 'sacred' && opts.hekateMode !== 'witch';
}
/** Hartowany w Ogniu: each fire hit deals 2 less (min 0). */
export function hardenedFireDamage(amount: number): number {
return Math.max(0, Math.trunc(amount) - 2);
}
/** Próg Obłędu: Dusza + 3 for everyone, Krasnolud +1. */
export function madnessThreshold(soul: number, raceId = ''): number {
return Math.max(0, Math.trunc(soul)) + 3 + (raceId.toLowerCase() === 'dwarf' || raceId.toLowerCase() === 'krasnolud' ? 1 : 0);
}
/** Golem limit: floor(Umysł / 2), min 1 (spell golems count too; two straw golems count as one). */
export function golemLimit(mind: number): number {
return Math.max(1, Math.floor(Math.max(0, mind) / 2));
}
/** Upadek: 1 per full metre above 2 m, cap 20; Ciało (Atletyka) 4:1 halves it (rounded down). */
export function fallDamage(metres: number, halved = false): number {
const raw = Math.min(20, Math.max(0, Math.floor(metres) - 2));
return halved ? Math.floor(raw / 2) : raw;
}
/** A fall of 4 m or more knocks the target Przewrócony unless the landing was cushioned. */
export const fallKnocksProne = (metres: number, cushioned = false) => !cushioned && metres >= 4;
/** Test Przeżycia (Umierający): tier by damage past the death threshold; each hit while dying raises S's T by 1. */
export function survivalTest(damagePastThreshold: number, hitsWhileDying = 0): { threshold: number; successes: number } {
const successes = damagePastThreshold <= 2 ? 1 : damagePastThreshold <= 5 ? 2 : 3;
return { threshold: 4 + Math.max(0, hitsWhileDying), successes };
}
/** Pełny Unik: Unik tests are one Stopień easier until the start of the next turn. */
export const FULL_DODGE_THRESHOLD_DELTA = -1;
/**
* Zapał regen at the start of a turn is blocked when since the start of the previous turn the character
* took a PAID extra Action or a PAID extra Immediate Action (beyond the free one). Zapał spent on prayers
* or Talents with their own Zapał cost does not count; Eliksir Szybkości's extra Action is free.
*/
export function zealRegenBlocked(paidExtraActions: number, paidExtraImmediateActions: number): boolean {
return paidExtraActions > 0 || paidExtraImmediateActions > 0;
}
/** Spells of category Obrażenia: +1 damage once, after multipliers/division; not for periodic/overcast/curses/artifacts. */
export const DAMAGE_CATEGORY_BONUS = 1;
/**
* Runiczny Pancerz – points disabled by ONE hit (hits are never added together).
* Default: a hit of more than 5 damage (after the Runiczny Pancerz step) disables 1 point.
* Shield-breaking effects (`step` 2): every 2 damage disable 1 point.
* Disabled points return at the end of the fight or when restored; only Rozerwanie Run
* destroys a point permanently.
*/
export function runicArmorPointsDisabled(damage: number, step?: number): number {
const d = Math.max(0, Math.floor(damage));
if (step && step > 0 && step !== DEFAULT_RUNIC_THRESHOLD) return Math.floor(d / step);
return d > DEFAULT_RUNIC_THRESHOLD ? 1 : 0;
}
export const DEFAULT_RUNIC_THRESHOLD = 5;
/** Damage profile of spells that treat the magical defences non-standardly (by spell slug). */
export interface SpellDamageProfile {
ignoreMagicalArmor?: boolean;
ignoreMagicalShield?: boolean;
ignorePhysicalArmor?: boolean;
/** 2 → every 2 (instead of 5) damage disable a Runiczny Pancerz point. */
runicArmorStep?: number;
/** Damage counts this many times against Magiczna Osłona. */
shieldMultiplier?: number;
/** Fully destroys Magiczne Tarcze and disables all Runiczny Pancerz (Prawdziwa Dezintegracja). */
destroyMagicalDefenses?: boolean;
}
const SHIELD_BREAKER: SpellDamageProfile = { runicArmorStep: 2, shieldMultiplier: 2 };
export const SPELL_DAMAGE_PROFILES: Readonly<Record<string, SpellDamageProfile>> = {
'ether-pierce': { ...SHIELD_BREAKER, ignorePhysicalArmor: true },
'divine-spear': { ...SHIELD_BREAKER, ignorePhysicalArmor: true },
'energy-wave': SHIELD_BREAKER,
'mysterious-stream': SHIELD_BREAKER,
'arrow-of-desire': SHIELD_BREAKER,
'last-judgment': SHIELD_BREAKER,
'crimson-bolt': { ignoreMagicalArmor: true, ignoreMagicalShield: true },
'true-disintegration': { ignorePhysicalArmor: true, destroyMagicalDefenses: true },
};
/** Krew – sources (Arcanum Sanguinis). */
export type KrewTalent = 'respect-for-life' | 'krwisty-pojedynkowicz' | 'rytualista-krwi';
/** Samookaleczenie: lose HP = Sukcesy; 1 Krew per HP (2 with Większe Samookaleczenie). */
export function krewFromSelfHarm(hpLost: number, greater = false): number {
return Math.max(0, hpLost) * (greater ? 2 : 1);
}
/** Kradzież Życia: 1 Krew per HP the target actually lost (2 with the greater version). */
export function krewFromLifeSteal(hpLost: number, greater = false): number {
return Math.max(0, hpLost) * (greater ? 2 : 1);
}
/** Złożenie w Ofierze: heal Sukcesy + Magia, gain Krew = Sukcesy. */
export function sacrificeResult(successes: number, magic: number): { heal: number; krew: number } {
return { heal: Math.max(0, successes) + Math.max(0, magic), krew: Math.max(0, successes) };
}
/**
* Wielka Ofiara: Sukcesy above the target's highest Atrybut kill it (all its Żywotność is lost),
* otherwise the mage heals Sukcesy. Krew gained = Żywotność the target lost.
*/
export function greatSacrificeResult(successes: number, highestAttribute: number, targetHealth: number): { kills: boolean; heal: number; krew: number } {
if (successes > highestAttribute) return { kills: true, heal: 0, krew: Math.max(0, targetHealth) };
return { kills: false, heal: Math.max(0, successes), krew: 0 };
}
/** Passive Krew: Szacunek do Życia 1 per 4 HP, Krwisty Pojedynkowicz 1 per 2 HP (slashing/piercing only). */
export function krewFromWeaponHpLoss(hpLost: number, talent: KrewTalent | null): number {
if (talent === 'respect-for-life') return Math.floor(Math.max(0, hpLost) / 4);
if (talent === 'krwisty-pojedynkowicz') return Math.floor(Math.max(0, hpLost) / 2);
return 0;
}
/** Krew at the start of a fight: Rytualista Krwi 4, everyone else 0. */
export function startingKrew(talent: KrewTalent | null): number {
return talent === 'rytualista-krwi' ? 4 : 0;
}
/** Runiczny Pancerz (Amulet Osłony): recharging costs 2 Mana per point, as an Akcja Natychmiastowa. */
export const RUNIC_ARMOR_MANA_PER_POINT = 2;
/** Points of Runiczny Pancerz that can actually be restored (never above max, never negative). */
export function runicArmorRestorable(value: number, max: number, requested: number): number {
return Math.max(0, Math.min(Math.floor(requested), max - value));
}
/** Mana needed to restore `points` points of Runiczny Pancerz. */
export function runicArmorManaCost(points: number): number {
return Math.max(0, points) * RUNIC_ARMOR_MANA_PER_POINT;
}
/** Eliksir Odnowienia Many: +3 mana (capped by the pool); quick use = 1d3 as Immediate Action. */
export const MANA_ELIXIR_NORMAL = 3;
export const MANA_ELIXIR_QUICK_DIE = 3;
@@ -172,3 +361,8 @@ export function negotiationBand(successes: number): number {
export function fearThresholdMod(sourceId: string | undefined, targetId: string | undefined): number {
return sourceId && targetId && sourceId === targetId ? 1 : 0;
}
/** Casting test pool with the player's +/- dice modifier (whole dice, never below 0). */
export function applyDiceModifier(pool: number, modifier?: number): number {
return Math.max(0, Math.trunc(Number(pool) || 0) + Math.trunc(Number(modifier) || 0));
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Runiczny Pancerz – a single hit of more than 5 damage disables 1 point (logic/damage.ts); disabled
* points return at the end of the fight or can be restored as an Akcja Natychmiastowa. The cost
* depends on the source: Amulet Osłony takes 2 Many per point. Only Rozerwanie Run destroys a point
* permanently (it lowers `magicalArmor.max`).
*/
import type { CastableActor } from './spell-cast';
import { runicArmorManaCost, runicArmorRestorable } from './rules';
/** End of fight: every disabled point returns. */
export async function restoreAllRunicArmor(actor: CastableActor): Promise<number> {
const armor = (actor.system.attributes as any).magicalArmor as { value: number; max: number } | undefined;
if (!armor || armor.value >= armor.max) return 0;
await actor.update({ 'system.attributes.magicalArmor.value': armor.max });
return armor.max - armor.value;
}
export interface RunicRestoreResult {
ok: boolean;
restored: number;
manaSpent: number;
reason?: string;
}
/**
* Spend Mana to restore up to `points` points of Runiczny Pancerz (capped at its max).
* Fails with `insufficient-mana` when the pool cannot pay for every requested point;
* pass `partial: true` to restore as many points as the pool affords instead.
*/
export async function rechargeRunicArmor(
actor: CastableActor,
points: number,
opts: { partial?: boolean } = {},
): Promise<RunicRestoreResult> {
const a = actor.system.attributes as any;
const armor = a.magicalArmor as { value: number; max: number } | undefined;
if (!armor) return { ok: false, restored: 0, manaSpent: 0, reason: 'no-runic-armor' };
let restored = runicArmorRestorable(armor.value, armor.max, points);
if (restored <= 0) return { ok: false, restored: 0, manaSpent: 0, reason: 'already-full' };
const mana = Number(a.mana?.value) || 0;
if (runicArmorManaCost(restored) > mana) {
if (!opts.partial) return { ok: false, restored: 0, manaSpent: 0, reason: 'insufficient-mana' };
restored = runicArmorRestorable(armor.value, armor.max, Math.floor(mana / runicArmorManaCost(1)));
if (restored <= 0) return { ok: false, restored: 0, manaSpent: 0, reason: 'insufficient-mana' };
}
const manaSpent = runicArmorManaCost(restored);
await actor.update({
'system.attributes.magicalArmor.value': armor.value + restored,
'system.attributes.mana.value': mana - manaSpent,
});
return { ok: true, restored, manaSpent };
}
+62 -36
View File
@@ -16,7 +16,8 @@
import { HbmTSRoll } from '../dice/ts-roll';
import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD, getMagicPowerEntry } from '../constants';
import { manaToBorrow, exhaustionDice, manaExhaustionTier, overLimitTest } 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,10 +74,17 @@ 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;
groupCasters?: string[];
/** Modifier to the number of dice in the casting test (+/-, pool never below 0). */
diceModifier?: number;
/** Override required successes (used by variableSuccesses summons). */
requiredOverride?: number;
speaker?: ChatMessage.SpeakerData;
@@ -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
const validation = validateCast(actor, spell, opts);
if (!validation.ok) {
@@ -127,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') {
@@ -144,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
@@ -198,8 +227,15 @@ 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.
profile: (() => {
const p = SPELL_DAMAGE_PROFILES[((spell as any).flags?.['hbm-rpg-v3']?.slug ?? (spell.system as any).slug) as string];
return p ? JSON.stringify(p) : '';
})(),
targets: targets.length > 0 ? targets : null,
};
}
@@ -253,7 +289,9 @@ async function renderCastCard(
async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOptions, rolls: CastRollEntry[]): Promise<HbmTSRoll | null> {
const baseCost = Math.max(0, spell.system.manaCost ?? 0);
const manaSpent = Math.max(baseCost, Math.floor(opts.manaSpent ?? baseCost));
const armorPenalty = castingPenaltyFor(actor);
// Ciężki armour: +2 Mana per spell, overcast included.
const manaSpent = Math.max(baseCost, Math.floor(opts.manaSpent ?? baseCost)) + armorPenalty.mana;
const a = actor.system.attributes;
// Casting beyond the remaining daily pool is allowed: the missing mana is
@@ -271,9 +309,11 @@ async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOp
const magicDice = typeof a.magic.dicePool === 'number'
? a.magic.dicePool
: getMagicPowerEntry(a.magic.actual ?? a.magic.value ?? 0).dicePool;
const pool = opts.castAsPrayer
// 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')}`
: `${spell.name}`;
@@ -339,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> {
const a = actor.system.attributes;
const required = Math.max(0, spell.system.complexityLevel ?? spell.system.components?.symbols?.length ?? 0);
// Poziom Skomplikowania = number of Symbols in the spell.
const required = Math.max(0, spell.system.components?.symbols?.length ?? 0);
const maxSymbols = Math.ceil(a.magic.actual / 2);
const armorPenalty = castingPenaltyFor(actor);
// Witch magic still has a mana cost.
const baseCost = Math.max(0, spell.system.manaCost ?? 0);
// Witch magic still has a mana cost (+2 in Ciężki armour).
const baseCost = Math.max(0, spell.system.manaCost ?? 0) + armorPenalty.mana;
const updates: Record<string, any> = { 'system.attributes.mana.value': a.mana.value - baseCost };
if (opts.castAsPrayer) {
updates['system.attributes.zeal.value'] = a.zeal.value - 1;
@@ -374,9 +397,10 @@ async function castWitch(actor: CastableActor, spell: SpellLike, opts: CastOptio
const magicDice = typeof (a.magic as any).dicePool === 'number'
? (a.magic as any).dicePool
: getMagicPowerEntry((a.magic as any).actual ?? a.magic.value ?? 0).dicePool;
const pool = opts.castAsPrayer
const basePool = opts.castAsPrayer
? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0);
: 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})`
: `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.witch')} (${required}/${maxSymbols})`;
@@ -403,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;
}
@@ -416,9 +441,10 @@ async function castBlood(actor: CastableActor, spell: SpellLike, opts: CastOptio
const magicDice = typeof (a.magic as any).dicePool === 'number'
? (a.magic as any).dicePool
: getMagicPowerEntry((a.magic as any).actual ?? a.magic.value ?? 0).dicePool;
const pool = opts.castAsPrayer
const basePool = opts.castAsPrayer
? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0);
: 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')}`
: `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.blood')}`;
+7 -1
View File
@@ -13,10 +13,16 @@
*/
import type { CastableActor } from './spell-cast';
import { DAMAGE_CATEGORY_BONUS } from './rules';
export interface DamageContext {
actor: CastableActor;
spellName?: string;
/**
* Spell is of category Obrażenia: +1 damage, added once after multipliers/division
* (not for periodic damage, overcast bonuses, other categories, curses or artifacts).
*/
categoryBonus?: boolean;
}
const ATTR_TOKENS = new Set(['body', 'mind', 'soul', 'magic', 'magicalAbilities', 'devotion']);
@@ -61,7 +67,7 @@ export function buildDamageFormula(spellDamageBase: string, ctx: DamageContext):
return String(v);
});
return formula;
return ctx.categoryBonus ? `(${formula}) + ${DAMAGE_CATEGORY_BONUS}` : formula;
}
export async function rollSpellDamage(spellDamageBase: string, ctx: DamageContext): Promise<Roll | null> {
+14 -23
View File
@@ -5,6 +5,7 @@
*/
import type { CastableActor, SpellLike, CastOptions } from './spell-cast';
import { resolveCastAsPrayer, actorHasTalent } from './rules';
export interface ValidationIssue {
code: string;
@@ -29,19 +30,7 @@ function getActorRaceId(actor: CastableActor): string {
return String(d.raceId ?? d.race ?? '').toLowerCase();
}
function actorHasTalent(actor: CastableActor, talentId: string): boolean {
const id = talentId.toLowerCase();
const items = (actor as any).items as Iterable<any> | undefined;
if (!items) return false;
for (const it of items) {
if (it.type !== 'talent') continue;
const t = String(it.system?.talentId ?? it.system?.id ?? it.name ?? '').toLowerCase();
if (t === id) return true;
}
return false;
}
function matchDiscipline(disciplineId: string, targetId: string): boolean {
export function matchDiscipline(disciplineId: string, targetId: string): boolean {
const d1 = disciplineId.toLowerCase().trim();
const d2 = targetId.toLowerCase().trim();
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'],
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'],
};
let key1: string | null = null;
@@ -63,6 +56,8 @@ function matchDiscipline(disciplineId: string, targetId: string): boolean {
}
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;
}
@@ -98,22 +93,17 @@ 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)) {
// 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' });
}
} 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 (bloodSpent > Number(a.blood?.value ?? 0)) {
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
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({
+2
View File
@@ -9,6 +9,7 @@ import {
AOE_SHAPES,
SPELL_DAMAGE_TYPES,
TRIGGER_EVENTS,
WOUND_KINDS,
} from '../constants';
const { ItemSheetV2 } = foundry.applications.sheets as unknown as {
@@ -67,6 +68,7 @@ export class HbmItemSheet extends HandlebarsApplicationMixin(ItemSheetV2) {
itemType: item.type,
bodyPartial: TYPE_PARTIAL[item.type] ?? 'systems/hbm-rpg-v3/templates/item/_unknown.hbs',
choices: buildChoiceMaps(),
woundChoices: Object.fromEntries(WOUND_KINDS.map((k) => [k, `HBM.gear.wound.${k}`])),
effects,
supportsEffects,
};
+5 -30
View File
@@ -1,3 +1,5 @@
import { fetchNbpRate, FALLBACK_RATES } from '../logic/nbp';
const { DialogV2 } = foundry.applications.api as unknown as {
DialogV2: any;
};
@@ -55,37 +57,10 @@ class MoneyConverterDialog extends DialogV2 {
export async function showMoneyConverter(actor: any): Promise<void> {
const pln = actor.system.details?.money ?? 0;
const currentYear = actor.system.details?.currentYear ?? 2026;
const currentRealYear = new Date().getFullYear();
const fetchNBP = async (currency: string, year: number): Promise<{ rate: number; date: string } | null> => {
if (year >= 2002 && year <= currentRealYear) {
for (let day = 1; day <= 7; day++) {
const d = `${year}-06-${String(day).padStart(2, '0')}`;
try {
const res = await fetch(`https://api.nbp.pl/api/exchangerates/rates/a/${currency}/${d}/?format=json`);
if (res.ok) {
const data = await res.json();
const r = data?.rates?.[0]?.mid;
if (typeof r === 'number') return { rate: r, date: d };
}
} catch (_) { /* ignore */ }
}
}
try {
const res = await fetch(`https://api.nbp.pl/api/exchangerates/rates/a/${currency}/?format=json`);
if (res.ok) {
const data = await res.json();
const r = data?.rates?.[0]?.mid;
const dt = data?.rates?.[0]?.effectiveDate || '';
if (typeof r === 'number') return { rate: r, date: dt };
}
} catch (_) { /* ignore */ }
return null;
};
const [eurData, usdData] = await Promise.all([fetchNBP('eur', currentYear), fetchNBP('usd', currentYear)]);
const eurRate = eurData?.rate ?? 4.35;
const usdRate = usdData?.rate ?? 4.00;
const [eurData, usdData] = await Promise.all([fetchNbpRate('eur', currentYear), fetchNbpRate('usd', currentYear)]);
const eurRate = eurData?.rate ?? FALLBACK_RATES.eur!;
const usdRate = usdData?.rate ?? FALLBACK_RATES.usd!;
const rateDate = eurData?.date ?? usdData?.date ?? 'default';
const isLive = !!(eurData || usdData);
+2 -2
View File
@@ -2,9 +2,9 @@
"id": "hbm-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).",
"version": "1.4.0",
"version": "1.5.2",
"manifest": "https://vtt-content.octoturge.com/packages/system/hbm-rpg-v3.json",
"download": "https://vtt-content.octoturge.com/packages/system/hbm-rpg-v3-v1.4.0.zip",
"download": "https://vtt-content.octoturge.com/packages/system/hbm-rpg-v3-v1.5.0.zip",
"compatibility": {
"minimum": "13",
"verified": "14"
+1 -1
View File
@@ -51,7 +51,7 @@
{{#if damage.targets}}
<div class="hbm-card-targets">
{{#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}}">
<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}}
</button>
{{/each}}
+6
View File
@@ -31,6 +31,11 @@
<option value="environmental" {{#if (eq system.weapon.damageType 'environmental')}}selected{{/if}}>{{localize 'HBM.gear.environmental'}}</option>
</select>
</label>
<label title="{{localize 'HBM.gear.woundKindsHint'}}">{{localize 'HBM.gear.woundKinds'}}:
<multi-select name="system.weapon.woundKinds">
{{selectOptions woundChoices selected=system.weapon.woundKinds localize=true}}
</multi-select>
</label>
</fieldset>
{{/if}}
@@ -52,6 +57,7 @@
</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 title="{{localize 'HBM.gear.ignoresCastingPenaltyHint'}}"><input type="checkbox" name="system.armor.ignoresCastingPenalty" {{#if system.armor.ignoresCastingPenalty}}checked{{/if}}/> {{localize 'HBM.gear.ignoresCastingPenalty'}}</label>
</fieldset>
{{/if}}
+1
View File
@@ -148,6 +148,7 @@
<label>{{localize 'HBM.spell.damageBase'}}:
<input type="text" name="system.damageBase" value="{{system.damageBase}}" placeholder="np. 1d6+magicalAbilities" />
</label>
<label><input type="checkbox" name="system.damageCategory" {{#if system.damageCategory}}checked{{/if}}/> {{localize 'HBM.spell.damageCategory'}}</label>
<label>{{localize 'HBM.spell.damageType'}}:
<select name="system.damageType">
{{#each @root.choices.damageTypes as |label key|}}
+126
View File
@@ -0,0 +1,126 @@
import { describe, it, expect, vi } from 'vitest';
import {
absorbBlood,
endOfFightKrew,
gainKrewFromWeaponDamage,
greatSacrifice,
resolveLifeSteal,
sacrifice,
selfHarm,
startOfFightKrew,
useLifeSteal,
} from '../../src/logic/blood-magic';
import { krewFromWeaponHpLoss, runicArmorPointsDisabled, startingKrew } from '../../src/logic/rules';
function makeActor(opts: { slugs?: string[]; blood?: number; hp?: number; zeal?: number; used?: Record<string, number> } = {}) {
const flags: Record<string, unknown> = {};
const actor: any = {
system: {
attributes: {
blood: { value: opts.blood ?? 0, max: 20 },
health: { value: opts.hp ?? 20, max: 30 },
zeal: { value: opts.zeal ?? 3, max: 5 },
magic: { value: 3, actual: 3 },
bloodUsed: { selfHarm: 0, lifeSteal: 0, sacrifice: 0, absorb: 0, ...(opts.used ?? {}) },
greatSacrificeUsed: 0,
},
},
items: (opts.slugs ?? []).map((slug) => ({ type: 'talent', system: { slug } })),
update: vi.fn(async () => undefined),
setFlag: vi.fn(async (_s: string, k: string, v: unknown) => { flags[k] = v; }),
getFlag: vi.fn((_s: string, k: string) => flags[k]),
unsetFlag: vi.fn(async (_s: string, k: string) => { delete flags[k]; }),
};
return actor;
}
describe('Samookaleczenie', () => {
it('loses HP = Sukcesy and gains 1 Krew per HP, once per round', async () => {
const actor = makeActor();
const res = await selfHarm(actor, { roller: async () => 3 });
expect(res).toMatchObject({ ok: true, hpLost: 3, krewGained: 3 });
expect(actor.update).toHaveBeenCalledWith(expect.objectContaining({
'system.attributes.health.value': 17,
'system.attributes.blood.value': 3,
'system.attributes.bloodUsed.selfHarm': 1,
}));
const again = makeActor({ used: { selfHarm: 1 } });
expect((await selfHarm(again, { roller: async () => 3 })).reason).toBe('already-used');
});
it('Większe Samookaleczenie needs the talent and gives 2 Krew per HP', async () => {
expect((await selfHarm(makeActor(), { greater: true, roller: async () => 2 })).reason).toBe('missing-talent');
const res = await selfHarm(makeActor({ slugs: ['krwisty-pojedynkowicz'] }), { greater: true, roller: async () => 2 });
expect(res.krewGained).toBe(4);
});
});
describe('Kradzież Życia', () => {
it('costs 1 Zapał, arms the next attack and pays Krew for HP actually lost', async () => {
const actor = makeActor({ zeal: 2 });
expect(await useLifeSteal(actor)).toEqual({ ok: true, stepsEasier: 1 });
expect(actor.update).toHaveBeenCalledWith({ 'system.attributes.zeal.value': 1 });
expect(await resolveLifeSteal(actor, 4)).toBe(4);
});
it('greater version pays 2 Krew per HP and may be used twice per round', async () => {
const actor = makeActor({ slugs: ['wieksza-kradziez-zycia'], used: { lifeSteal: 1 } });
expect((await useLifeSteal(actor, { greater: true })).stepsEasier).toBe(2);
expect(await resolveLifeSteal(actor, 3)).toBe(6);
const spent = makeActor({ slugs: ['wieksza-kradziez-zycia'], used: { lifeSteal: 2 } });
expect((await useLifeSteal(spent, { greater: true })).reason).toBe('already-used');
});
});
describe('Złożenie w Ofierze / Wielka Ofiara', () => {
it('heals Sukcesy + Magia and gains Krew = Sukcesy', async () => {
const actor = makeActor({ hp: 10 });
expect(await sacrifice(actor, 2)).toMatchObject({ ok: true, hpGained: 5, krewGained: 2 });
});
it('Wielka Ofiara kills when Sukcesy exceed the highest Atrybut, else heals', async () => {
const actor = makeActor({ slugs: ['rytualista-krwi'], hp: 10 });
const kill = await greatSacrifice(actor, 6, { highestAttribute: 5, health: 12 });
expect(kill).toMatchObject({ ok: true, kills: true, targetHpLost: 12, krewGained: 12 });
const other = makeActor({ slugs: ['rytualista-krwi'], hp: 10 });
expect(await greatSacrifice(other, 4, { highestAttribute: 5, health: 12 })).toMatchObject({ kills: false, hpGained: 4, krewGained: 0 });
expect((await sacrifice(actor, 2)).reason).toBe('replaced-by-wielka-ofiara');
});
});
describe('Wchłanianie Krwi', () => {
it('needs at least 1 Krew; 2 HP per Krew once per round, 3 HP greater', async () => {
expect((await absorbBlood(makeActor({ blood: 5 }), 0)).reason).toBe('minimum-one-blood');
expect((await absorbBlood(makeActor({ blood: 5, hp: 10 }), 3))).toMatchObject({ ok: true, hpGained: 6 });
expect((await absorbBlood(makeActor({ blood: 5, used: { absorb: 1 } }), 1)).reason).toBe('already-used');
expect((await absorbBlood(makeActor({ blood: 5, hp: 10 }), 2, { greater: true }))).toMatchObject({ hpGained: 6 });
});
});
describe('passive Krew', () => {
it('Szacunek do Życia 1 per 4 HP, Krwisty Pojedynkowicz 1 per 2 HP', async () => {
expect(krewFromWeaponHpLoss(9, 'respect-for-life')).toBe(2);
expect(krewFromWeaponHpLoss(9, 'krwisty-pojedynkowicz')).toBe(4);
expect(krewFromWeaponHpLoss(9, 'rytualista-krwi')).toBe(0);
expect(await gainKrewFromWeaponDamage(makeActor({ slugs: ['respect-for-life'] }), 9)).toBe(2);
expect(await gainKrewFromWeaponDamage(makeActor(), 9)).toBe(0);
});
it('fight start/end: Rytualista starts with 4, leftover Krew heals 2 HP each', async () => {
expect(startingKrew('rytualista-krwi')).toBe(4);
const rit = makeActor({ slugs: ['rytualista-krwi'], blood: 0 });
await startOfFightKrew(rit);
expect(rit.update).toHaveBeenCalledWith({ 'system.attributes.blood.value': 4 });
const end = makeActor({ slugs: ['respect-for-life'], blood: 5, hp: 10 });
expect(await endOfFightKrew(end)).toBe(10);
});
});
describe('Runiczny Pancerz – single hit', () => {
it('> 5 disables 1 point; shield-breakers disable per 2 damage', () => {
expect(runicArmorPointsDisabled(5)).toBe(0);
expect(runicArmorPointsDisabled(6)).toBe(1);
expect(runicArmorPointsDisabled(40)).toBe(1);
expect(runicArmorPointsDisabled(7, 2)).toBe(3);
});
});
+35
View File
@@ -80,6 +80,41 @@ describe('applyDamage', () => {
expect(r.hpDamage).toBe(6);
});
it('one hit above 5 (after Runiczny Pancerz) disables 1 point; hits never accumulate', async () => {
const big = makeActor({ magicalArmor: { value: 3, max: 3 } });
const r = await applyDamage(big.actor, { amount: 10, postChat: false });
expect(r.runicArmorDisabled).toBe(1);
expect(big.actor.update).toHaveBeenCalledWith(expect.objectContaining({ 'system.attributes.magicalArmor.value': 2 }));
const small = makeActor({ magicalArmor: { value: 3, max: 3 } });
await applyDamage(small.actor, { amount: 7, postChat: false });
await applyDamage(small.actor, { amount: 7, postChat: false });
expect(small.updates.every((u) => u['system.attributes.magicalArmor.value'] === 3)).toBe(true);
});
it('shield-breakers: every 2 damage disable a point and damage counts double against Magiczna Osłona', async () => {
const { actor } = makeActor({ magicalArmor: { value: 4, max: 4 }, magicalShield: { value: 6 } });
const r = await applyDamage(actor, { amount: 10, runicArmorStep: 2, shieldMultiplier: 2, postChat: false });
expect(r.runicArmorDisabled).toBe(4);
expect(r.absorbed.magicalShield).toBe(3);
expect(r.hpDamage).toBe(3);
});
it('Prawdziwa Dezintegracja destroys shield and disables all Runiczny Pancerz', async () => {
const { actor } = makeActor({ magicalArmor: { value: 3, max: 3 }, magicalShield: { value: 50 } });
await applyDamage(actor, { amount: 10, destroyMagicalDefenses: true, ignorePhysicalArmor: true, postChat: false });
expect(actor.update).toHaveBeenCalledWith(expect.objectContaining({
'system.attributes.magicalArmor.value': 0,
'system.attributes.magicalShield.value': 0,
}));
});
it('Rozerwanie Run permanently lowers the maximum', async () => {
const { actor } = makeActor({ magicalArmor: { value: 3, max: 3 } });
const r = await applyDamage(actor, { amount: 1, destroyRunicPoints: 1, postChat: false });
expect(r.runicArmorDestroyed).toBe(1);
expect(actor.update).toHaveBeenCalledWith(expect.objectContaining({ 'system.attributes.magicalArmor.max': 2 }));
});
it('marks shield as dropped when reduced to 0', async () => {
const { actor } = makeActor({ magicalShield: { value: 3 } });
const r = await applyDamage(actor, { amount: 5, postChat: false });
+31
View File
@@ -0,0 +1,31 @@
import { describe, it, expect, vi } from 'vitest';
import { fetchNbpRate } from '../../src/logic/nbp';
const ok = (mid: number, effectiveDate: string) => ({ ok: true, json: async () => ({ rates: [{ mid, effectiveDate }] }) });
const notFound = { ok: false, json: async () => ({}) };
const now = new Date('2026-10-20');
describe('fetchNbpRate', () => {
it('uses the June window for a past year', async () => {
const f = vi.fn().mockResolvedValue(ok(4.4, '2024-06-03'));
expect(await fetchNbpRate('EUR', 2024, now, f)).toEqual({ rate: 4.4, date: '2024-06-03' });
expect(f.mock.calls[0]![0]).toContain('/eur/2024-06-01/2024-06-14/');
});
it('uses the latest rate for a future year without a dated request', async () => {
const f = vi.fn().mockResolvedValue(ok(4.3, '2026-10-19'));
expect(await fetchNbpRate('eur', 2031, now, f)).toEqual({ rate: 4.3, date: '2026-10-19' });
expect(f).toHaveBeenCalledTimes(1);
expect(f.mock.calls[0]![0]).not.toMatch(/\d{4}-\d{2}-\d{2}\//);
});
it('falls back to the latest rate when the dated window has no table', async () => {
const f = vi.fn().mockResolvedValueOnce(notFound).mockResolvedValueOnce(ok(4.2, '2026-10-19'));
expect((await fetchNbpRate('eur', 2026, now, f))?.rate).toBe(4.2);
});
it('returns null when NBP is unreachable', async () => {
const f = vi.fn().mockRejectedValue(new Error('offline'));
expect(await fetchNbpRate('eur', 2024, now, f)).toBeNull();
});
});
+3 -3
View File
@@ -17,7 +17,7 @@ function makeActor(overrides: Partial<any> = {}) {
zeal: { value: 0, max: 5 },
blood: { value: 0, max: 6 },
elixirTolerance: 2,
magicalArmor: { value: 4, runicCounter: 3 },
magicalArmor: { value: 4, max: 4, runicCounter: 3 },
...overrides.attributes,
},
skills: overrides.skills ?? {},
@@ -94,7 +94,7 @@ describe('rest', () => {
mana: { value: 1, max: 20 },
zeal: { value: 0, max: 5 },
blood: { value: 0, max: 6 },
magicalArmor: { value: 4, runicCounter: 3 },
magicalArmor: { value: 1, max: 4, runicCounter: 3 },
},
});
const r = await rest(actor, 'long');
@@ -103,7 +103,7 @@ describe('rest', () => {
expect(updates['system.attributes.mana.value']).toBe(20);
expect(updates['system.attributes.zeal.value']).toBe(5);
expect(updates['system.attributes.blood.value']).toBe(6);
expect(updates['system.attributes.magicalArmor.runicCounter']).toBe(0);
expect(updates['system.attributes.magicalArmor.value']).toBe(4);
expect(r.toleranceRecovered).toBe(1);
expect(updates['system.attributes.breathersTaken']).toBe(0);
expect(updates['system.attributes.manaBorrowed']).toBe(0);
+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]);
});
});
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);
});
});
+39
View File
@@ -0,0 +1,39 @@
import { describe, it, expect, vi } from 'vitest';
import { rechargeRunicArmor } from '../../src/logic/runic-armor';
import { runicArmorManaCost, runicArmorRestorable } from '../../src/logic/rules';
function makeActor(armor: { value: number; max: number }, mana: number) {
const update = vi.fn().mockResolvedValue(undefined);
return { actor: { system: { attributes: { magicalArmor: armor, mana: { value: mana, max: 30 } } }, update } as any, update };
}
describe('Runiczny Pancerz restore', () => {
it('costs 2 Mana per point and never exceeds max', () => {
expect(runicArmorManaCost(3)).toBe(6);
expect(runicArmorRestorable(2, 4, 5)).toBe(2);
expect(runicArmorRestorable(4, 4, 1)).toBe(0);
});
it('restores points and spends mana', async () => {
const { actor, update } = makeActor({ value: 1, max: 4 }, 10);
const res = await rechargeRunicArmor(actor, 2);
expect(res).toEqual({ ok: true, restored: 2, manaSpent: 4 });
expect(update).toHaveBeenCalledWith({
'system.attributes.magicalArmor.value': 3,
'system.attributes.mana.value': 6,
});
});
it('refuses when mana is short, unless partial', async () => {
const { actor, update } = makeActor({ value: 0, max: 4 }, 5);
expect((await rechargeRunicArmor(actor, 3)).reason).toBe('insufficient-mana');
expect(update).not.toHaveBeenCalled();
const res = await rechargeRunicArmor(actor, 3, { partial: true });
expect(res).toEqual({ ok: true, restored: 2, manaSpent: 4 });
});
it('reports already-full armor', async () => {
const { actor } = makeActor({ value: 4, max: 4 }, 10);
expect((await rechargeRunicArmor(actor, 1)).reason).toBe('already-full');
});
});
+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);
});
});
+17
View File
@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest';
import { parseWoundKinds } from '../../scripts/parsers/gear-parser';
describe('gear-parser parseWoundKinds', () => {
it('maps English and Polish labels, single or listed', () => {
expect(parseWoundKinds('slashing')).toEqual(['slashing']);
expect(parseWoundKinds('kłute')).toEqual(['piercing']);
expect(parseWoundKinds('piercing, slashing')).toEqual(['piercing', 'slashing']);
expect(parseWoundKinds('slashing, piercing, blunt, slashing')).toEqual(['slashing', 'piercing', 'blunt']);
});
it('never guesses: missing or unknown stays empty', () => {
expect(parseWoundKinds(undefined)).toEqual([]);
expect(parseWoundKinds('')).toEqual([]);
expect(parseWoundKinds('ognista')).toEqual([]);
});
});
+3 -1
View File
@@ -19,8 +19,10 @@ describe('spell-parser', () => {
expect(s.damageBase).toBe('magicalAbilities/2');
expect(s.damageType).toBe('magical');
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.damageCategory).toBe(true);
expect((docs.find((d) => d.id === 'crimson-bolt')!.system as any).damageCategory).toBe(true);
for (const d of docs) {
const sys = d.system as any;