Author SHA1 Message Date
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
39 changed files with 1215 additions and 209 deletions
+48
View File
@@ -5,6 +5,54 @@ All notable changes to the **HbM RPG v3** Foundry VTT system.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.5.2] - 2026-10-26
### 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 ## [1.4.0] - 2026-10-20
Vault sync ("Foundry Sync Brief 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. - **TS d6 dice pool roller** (`HbmTSRoll`) - `1` auto-fail, `6` auto-success, configurable Threshold (T) and required Successes (Y), plus T-step / Y-step modifiers driven by Active Effects.
- **Derived stats** - `health.max = 3 × (body + mind + soul)`, `zeal.max = ceil(soul/2)`, initiative pool, mana table lookup (Magic Power Level 0–X), blood pool (`body + soul`), elixir tolerance cap (`body + 1`), insanity counter. - **Derived stats** - `health.max = 3 × (body + mind + soul)`, `zeal.max = ceil(soul/2)`, initiative pool, mana table lookup (Magic Power Level 0–X), blood pool (`body + soul`), elixir tolerance cap (`body + 1`), insanity counter.
- **4-layer damage model** - Magical Armor (with runic counter) → Magical Shield → Physical Armor (DR + condition) → Health. `Apply Damage` dialog with bypass flags; auto-applies `Oszołomiony` on heavy HP loss. - **4-layer damage model** - Magical Armor (with runic counter) → Magical Shield → Physical Armor (DR + condition) → Health. `Apply Damage` dialog with bypass flags; auto-applies `Oszołomiony` on heavy HP loss.
- **Combat hooks** - Mana resets each round; Zeal +1 each turn; turns auto-skipped for Nieprzytomny / Obezwładniony; Podpalony ticks 1 environmental damage; Umierający prompts a Death Save. - **Combat hooks** - Mana is a daily pool (not reset per round; recovered by rests); Zeal +1 each turn; Immediate Action and Krew counters reset each round; turns auto-skipped for Nieprzytomny / Obezwładniony; Podpalony ticks 1 environmental damage; Umierający prompts a Death Save.
- **Spell-cast workflow** - `validateCast()` gates on resources / race / talent / discipline / deity / group / inCombat / superspell / witch-symbol; dispatcher routes to standard / Sacred / Witch / Blood / Hekate hybrid; `rollSpellDamage()` parses formula tokens; reactive triggers (`killWithWeapon`, `targetCastsSpell`, `damageTaken`, `turnStart`). - **Spell-cast workflow** - `validateCast()` gates on resources / race / talent / discipline / deity / group / inCombat / superspell / witch-symbol; dispatcher routes to standard / Sacred / Witch / Blood / Hekate hybrid; `rollSpellDamage()` parses formula tokens; reactive triggers (`killWithWeapon`, `targetCastsSpell`, `damageTaken`, `turnStart`).
- **Cast dialog** - mana / zeal / blood inputs, group-caster picker, Hekate toggle, GM bypass checkboxes (superspell, non-combat block), inline validation feedback. - **Cast dialog** - mana / zeal / blood inputs, group-caster picker, Hekate toggle, GM bypass checkboxes (superspell, non-combat block), inline validation feedback.
- **Mechanics modules** (`game.hbm.*`) - `bloodMagic` (Self-Harm, Life Steal, talent integration), `abyssMagic` (Aspects vs Primal, Mistrz Losu penalty, `Dary Otchłani`, insanity gain), `brewing` (TS test + tolerance counter), `rest` (short / long), `trade` (founding company workflow, transactions, smuggling). - **Mechanics modules** (`game.hbm.*`) - `bloodMagic` (Self-Harm, Life Steal, talent integration), `abyssMagic` (Aspects vs Primal, Mistrz Losu penalty, `Dary Otchłani`, insanity gain), `brewing` (TS test + tolerance counter), `rest` (short / long), `trade` (founding company workflow, transactions, smuggling).
+20 -2
View File
@@ -1,5 +1,8 @@
{ {
"HBM": { "HBM": {
"insanity": {
"illnessRoll": "Madness threshold reached - Mental Illness (d100, re-roll duplicates)"
},
"system": { "system": {
"name": "Homebrew Magic: RPG v3" "name": "Homebrew Magic: RPG v3"
}, },
@@ -138,6 +141,7 @@
"aoeUnit": "Unit", "aoeUnit": "Unit",
"damage": "Damage", "damage": "Damage",
"damageBase": "Base damage", "damageBase": "Base damage",
"damageCategory": "Category Obrażenia (+1 damage)",
"damageType": "Damage type", "damageType": "Damage type",
"ignoresArmor": "Ignores armor", "ignoresArmor": "Ignores armor",
"statusEffects": "Status effects", "statusEffects": "Status effects",
@@ -175,6 +179,7 @@
"artifice": "Artifice", "artifice": "Artifice",
"golemancy": "Golemancy", "golemancy": "Golemancy",
"runes": "Runic Magic", "runes": "Runic Magic",
"runic": "Runic Magic",
"manaSourceMage": "Mana Source", "manaSourceMage": "Mana Source",
"illusion": "Illusion", "illusion": "Illusion",
"sacred": "Sacred Magic", "sacred": "Sacred Magic",
@@ -239,6 +244,13 @@
"physical": "Physical", "physical": "Physical",
"magical": "Magical", "magical": "Magical",
"environmental": "Environmental", "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", "properties": "Properties",
"damageReduction": "Damage Reduction", "damageReduction": "Damage Reduction",
"damageReductionBonus": "DR Bonus", "damageReductionBonus": "DR Bonus",
@@ -253,6 +265,8 @@
"heavy": "Heavy", "heavy": "Heavy",
"shield": "Shield", "shield": "Shield",
"stealthDisadvantage": "Stealth Disadvantage", "stealthDisadvantage": "Stealth Disadvantage",
"ignoresCastingPenalty": "Does not hamper spellcasting",
"ignoresCastingPenaltyHint": "Special case (e.g. Cultist medium armour, Ninurta's Breastplate): skips the casting penalty",
"strengthRequirement": "Strength Requirement", "strengthRequirement": "Strength Requirement",
"rarity": "Rarity", "rarity": "Rarity",
"value": "Value", "value": "Value",
@@ -356,8 +370,12 @@
}, },
"spellCast": { "spellCast": {
"title": "Casting spell", "title": "Casting spell",
"route": "Casting route",
"routeSpell": "Regular spell (Magia + Zdolności Magiczne, Mana only)",
"diceModifier": "Dice modifier",
"diceModifierHint": "+/- dice in the casting test (the pool never drops below 0)",
"manaSpent": "Mana spent", "manaSpent": "Mana spent",
"cannotBeUnraveled": "Cannot be unraveled", "cannotBeUnraveled": "Cannot be dispelled",
"zealSpent": "Zeal spent", "zealSpent": "Zeal spent",
"bloodSpent": "Blood spent", "bloodSpent": "Blood spent",
"hekateMode": "Hekate mode (Sacred + Witch hybrid)", "hekateMode": "Hekate mode (Sacred + Witch hybrid)",
@@ -416,7 +434,7 @@
} }
}, },
"combat": { "combat": {
"newRound": "New round - Mana reset", "newRound": "New round - Immediate Action and Krew counters reset",
"newTurn": "New turn - Zeal +1", "newTurn": "New turn - Zeal +1",
"turnSkipped": "Turn skipped (condition)", "turnSkipped": "Turn skipped (condition)",
"deathSavePrompt": "Make a Death Saving Throw (Body, T:4, Y:1)" "deathSavePrompt": "Make a Death Saving Throw (Body, T:4, Y:1)"
+19 -1
View File
@@ -1,5 +1,8 @@
{ {
"HBM": { "HBM": {
"insanity": {
"illnessRoll": "Próg Obłędu osiągnięty - Choroba Psychiczna (k100, powtórz duplikaty)"
},
"system": { "system": {
"name": "Homebrew Magic: RPG v3" "name": "Homebrew Magic: RPG v3"
}, },
@@ -138,6 +141,7 @@
"aoeUnit": "Jednostka", "aoeUnit": "Jednostka",
"damage": "Obrażenia", "damage": "Obrażenia",
"damageBase": "Obrażenia bazowe", "damageBase": "Obrażenia bazowe",
"damageCategory": "Kategoria Obrażenia (+1 do Obrażeń)",
"damageType": "Typ obrażeń", "damageType": "Typ obrażeń",
"ignoresArmor": "Ignoruje pancerz", "ignoresArmor": "Ignoruje pancerz",
"statusEffects": "Efekty statusu", "statusEffects": "Efekty statusu",
@@ -175,6 +179,7 @@
"artifice": "Rzemiosło Artefaktów", "artifice": "Rzemiosło Artefaktów",
"golemancy": "Golemancja", "golemancy": "Golemancja",
"runes": "Magia Runiczna", "runes": "Magia Runiczna",
"runic": "Magia Runiczna",
"manaSourceMage": "Źródło Mocy", "manaSourceMage": "Źródło Mocy",
"illusion": "Magia Iluzji", "illusion": "Magia Iluzji",
"sacred": "Magia Sakralna", "sacred": "Magia Sakralna",
@@ -259,6 +264,13 @@
"physical": "Fizyczne", "physical": "Fizyczne",
"magical": "Magiczne", "magical": "Magiczne",
"environmental": "Środowiskowe", "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", "properties": "Właściwości",
"damageReduction": "Redukcja Obrażeń", "damageReduction": "Redukcja Obrażeń",
"damageReductionBonus": "Premia do Redukcji", "damageReductionBonus": "Premia do Redukcji",
@@ -273,6 +285,8 @@
"heavy": "Ciężka", "heavy": "Ciężka",
"shield": "Tarcza", "shield": "Tarcza",
"stealthDisadvantage": "Utrudnia Skradanie", "stealthDisadvantage": "Utrudnia Skradanie",
"ignoresCastingPenalty": "Nie utrudnia rzucania zaklęć",
"ignoresCastingPenaltyHint": "Przypadek szczególny (np. Średnia zbroja kultysty, Napierśnik Ninurty): pomija karę do rzucania zaklęć",
"strengthRequirement": "Wymóg Siły", "strengthRequirement": "Wymóg Siły",
"rarity": "Rzadkość", "rarity": "Rzadkość",
"value": "Wartość", "value": "Wartość",
@@ -376,6 +390,10 @@
}, },
"spellCast": { "spellCast": {
"title": "Rzucanie zaklęcia", "title": "Rzucanie zaklęcia",
"route": "Sposób rzucenia",
"routeSpell": "Zwykłe zaklęcie (Magia + Zdolności Magiczne, tylko Mana)",
"diceModifier": "Modyfikator liczby kości",
"diceModifierHint": "+/- kości w Teście rzucania (liczba kości nie spadnie poniżej 0)",
"manaSpent": "Wydana mana", "manaSpent": "Wydana mana",
"cannotBeUnraveled": "Nie da się rozpleść", "cannotBeUnraveled": "Nie da się rozpleść",
"cast": "Rzuć", "cast": "Rzuć",
@@ -419,7 +437,7 @@
} }
}, },
"combat": { "combat": {
"newRound": "Nowa runda - Mana zresetowana", "newRound": "Nowa runda - liczniki Akcji Natychmiastowych i Krwi zresetowane",
"newTurn": "Nowa tura - Zapał +1", "newTurn": "Nowa tura - Zapał +1",
"turnSkipped": "Tura pominięta (stan)", "turnSkipped": "Tura pominięta (stan)",
"deathSavePrompt": "Wykonaj Rzut Obronny przeciw Śmierci (Ciało, T:4, Y:1)" "deathSavePrompt": "Wykonaj Rzut Obronny przeciw Śmierci (Ciało, T:4, Y:1)"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hbm-rpg-v3-foundry", "name": "hbm-rpg-v3-foundry",
"version": "1.4.0", "version": "1.5.2",
"description": "Foundry VTT system for Homebrew Magic: Role Playing Game v3", "description": "Foundry VTT system for Homebrew Magic: Role Playing Game v3",
"private": true, "private": true,
"type": "module", "type": "module",
+32 -1
View File
@@ -38,6 +38,27 @@ export const parseGear: ParserFn = async (ctx: ParserContext): Promise<ParsedDoc
return docs; 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 } { function extractMarkdown(raw: string): { frontmatter: Record<string, string>; content: string } {
const fm: Record<string, string> = {}; const fm: Record<string, string> = {};
let content = raw; let content = raw;
@@ -48,9 +69,17 @@ function extractMarkdown(raw: string): { frontmatter: Record<string, string>; co
const fmText = raw.slice(3, endIdx).trim(); const fmText = raw.slice(3, endIdx).trim();
content = raw.slice(endIdx + 3).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) { 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(':'); const colonIdx = line.indexOf(':');
listKey = colonIdx > -1 && !line.slice(colonIdx + 1).trim() ? line.slice(0, colonIdx).trim() : '';
if (colonIdx > -1) { if (colonIdx > -1) {
const key = line.slice(0, colonIdx).trim(); const key = line.slice(0, colonIdx).trim();
let val = line.slice(colonIdx + 1).trim(); let val = line.slice(colonIdx + 1).trim();
@@ -91,6 +120,7 @@ function buildGearDoc(name: string, fm: Record<string, string>, content: string,
weapon: { weapon: {
damage: '', damage: '',
damageType: 'physical', damageType: 'physical',
woundKinds: [],
properties: [], properties: [],
}, },
armor: { armor: {
@@ -105,6 +135,7 @@ function buildGearDoc(name: string, fm: Record<string, string>, content: string,
if (category === 'weapon') { if (category === 'weapon') {
system.weapon.damage = fm.damage || fm.obrazenia || ''; system.weapon.damage = fm.damage || fm.obrazenia || '';
system.weapon.woundKinds = parseWoundKinds(fm.wound || fm.rana);
if (fm.traits || fm.cechy) { if (fm.traits || fm.cechy) {
system.weapon.properties = parseList(fm.traits || fm.cechy); system.weapon.properties = parseList(fm.traits || fm.cechy);
} }
+2 -2
View File
@@ -19,7 +19,6 @@ const SpellNoteSchema = z.object({
bloodCost: z.number().int().min(0).default(0), bloodCost: z.number().int().min(0).default(0),
threshold: z.number().int().min(1).max(6), threshold: z.number().int().min(1).max(6),
successes: z.number().int().min(1), successes: z.number().int().min(1),
complexityLevel: z.number().int().min(0).default(0),
isSuperspell: z.boolean().default(false), isSuperspell: z.boolean().default(false),
requiresGroupCast: z.boolean().default(false), requiresGroupCast: z.boolean().default(false),
minCasters: z.number().int().min(1).default(1), minCasters: z.number().int().min(1).default(1),
@@ -36,6 +35,7 @@ const SpellNoteSchema = z.object({
targets: z.coerce.string().default(''), targets: z.coerce.string().default(''),
duration: z.coerce.string().default(''), duration: z.coerce.string().default(''),
damageBase: z.coerce.string().default(''), damageBase: z.coerce.string().default(''),
damageCategory: z.boolean().default(false),
damageType: z.string().default('magical'), damageType: z.string().default('magical'),
ignoresArmor: z.boolean().default(false), ignoresArmor: z.boolean().default(false),
statusEffects: z.array(z.string()).default([]), statusEffects: z.array(z.string()).default([]),
@@ -93,6 +93,7 @@ function toSystem(n: SpellNote): Record<string, unknown> {
duration: n.duration, duration: n.duration,
areaOfEffect: deriveAreaOfEffect(n.targets), areaOfEffect: deriveAreaOfEffect(n.targets),
damageBase: n.damageBase, damageBase: n.damageBase,
damageCategory: n.damageCategory,
damageType: n.damageType || 'magical', damageType: n.damageType || 'magical',
ignoresArmor: n.ignoresArmor, ignoresArmor: n.ignoresArmor,
statusEffects: n.statusEffects, statusEffects: n.statusEffects,
@@ -105,7 +106,6 @@ function toSystem(n: SpellNote): Record<string, unknown> {
nonCombatOnly: n.nonCombatOnly, nonCombatOnly: n.nonCombatOnly,
overcastOptions: n.overcast.map((description) => ({ description, manaPerStep: 1 })), overcastOptions: n.overcast.map((description) => ({ description, manaPerStep: 1 })),
triggers: n.triggers, triggers: n.triggers,
complexityLevel: n.complexityLevel,
}; };
} }
+6 -1
View File
@@ -135,7 +135,7 @@ export const SPELL_SCHOOLS = [
// Academic disciplines // Academic disciplines
'alchemyTransmutation', 'alchemyBrewing', 'botany', 'alchemyTransmutation', 'alchemyBrewing', 'botany',
'elementsAir', 'elementsWater', 'elementsFire', 'elementsEarth', 'elementsAir', 'elementsWater', 'elementsFire', 'elementsEarth',
'artifice', 'golemancy', 'runes', 'manaSourceMage', 'artifice', 'golemancy', 'runes', 'runic', 'manaSourceMage',
'illusion', 'sacred', 'sacredExorcism', 'witch', 'necromancy', 'illusion', 'sacred', 'sacredExorcism', 'witch', 'necromancy',
// Forbidden / extra-academic // Forbidden / extra-academic
'blood', 'crimson', 'abyssAspects', 'abyssPrimal', 'wildWitch', 'blood', 'crimson', 'abyssAspects', 'abyssPrimal', 'wildWitch',
@@ -192,6 +192,11 @@ export type GearCategory = (typeof GEAR_CATEGORIES)[number];
export const DAMAGE_TYPES = ['physical', 'magical', 'environmental'] as const; export const DAMAGE_TYPES = ['physical', 'magical', 'environmental'] as const;
export type DamageType = (typeof DAMAGE_TYPES)[number]; 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 */ /** Armor types */
export const ARMOR_TYPES = ['light', 'medium', 'heavy', 'shield'] as const; export const ARMOR_TYPES = ['light', 'medium', 'heavy', 'shield'] as const;
export type ArmorType = (typeof ARMOR_TYPES)[number]; 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 }), breathersTaken: makeIntField(0, { min: 0 }),
// Akcje Natychmiastowe used in the current round (1 free, Zapał buys more). // Akcje Natychmiastowe used in the current round (1 free, Zapał buys more).
immediateActionsUsed: makeIntField(0, { min: 0 }), immediateActionsUsed: makeIntField(0, { min: 0 }),
// Insanity points (Klątwa Otchłani VIII). Each Abyss exposure may add 1; rolls // Blood-magic actions used this round (Samookaleczenie, Kradzież Życia, Złożenie w Ofierze, Wchłanianie Krwi).
// mental-condition table when reaching thresholds. 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 }), insanity: makeIntField(0, { min: 0 }),
// Mental illnesses gained so far (names). The 6th one means the character is lost.
mentalIllnesses: makeArrayField(new f.StringField({ blank: false })),
magicalArmor: new f.SchemaField({ magicalArmor: new f.SchemaField({
value: makeIntField(0, { min: 0 }), value: makeIntField(0, { min: 0 }),
max: makeIntField(0, { min: 0 }), max: makeIntField(0, { min: 0 }),
@@ -152,7 +163,10 @@ export class CharacterData extends foundry.abstract.TypeDataModel {
manaBorrowed: number; manaBorrowed: number;
breathersTaken: number; breathersTaken: number;
immediateActionsUsed: number; immediateActionsUsed: number;
bloodUsed: { selfHarm: number; lifeSteal: number; sacrifice: number; absorb: number };
greatSacrificeUsed: number;
insanity: number; insanity: number;
mentalIllnesses: string[];
magicalArmor: { value: number; max: number; runicCounter: number }; magicalArmor: { value: number; max: number; runicCounter: number };
magicalShield: { value: number }; magicalShield: { value: number };
physicalArmor: { value: number; max: number; condition: number; conditionMax: number }; physicalArmor: { value: number; max: number; condition: number; conditionMax: number };
+5 -1
View File
@@ -6,7 +6,7 @@ import {
makeArrayField, makeArrayField,
fields, fields,
} from './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`. * 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[], choices: DAMAGE_TYPES as unknown as readonly string[],
}), }),
properties: makeArrayField(new f.StringField({ blank: false })), 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({ armor: new f.SchemaField({
@@ -51,6 +53,8 @@ export class GearData extends foundry.abstract.TypeDataModel {
}), }),
stealthDisadvantage: makeBoolField(false), stealthDisadvantage: makeBoolField(false),
strengthRequirement: makeIntField(0, { min: 0 }), strengthRequirement: makeIntField(0, { min: 0 }),
// Special-case armour that never hampers casting (Średnia zbroja kultysty, Napierśnik Ninurty).
ignoresCastingPenalty: makeBoolField(false),
}), }),
}; };
} }
+3 -1
View File
@@ -63,6 +63,8 @@ export class SpellData extends foundry.abstract.TypeDataModel {
// Damage // Damage
damageBase: makeStringField(''), damageBase: makeStringField(''),
// Category Obrażenia: +1 damage (Rodzaje Obrażeń). Vault frontmatter has no category yet.
damageCategory: makeBoolField(false),
damageType: makeStringField('magical', { blank: false, choices: SPELL_DAMAGE_TYPES as unknown as readonly string[] }), damageType: makeStringField('magical', { blank: false, choices: SPELL_DAMAGE_TYPES as unknown as readonly string[] }),
ignoresArmor: makeBoolField(false), ignoresArmor: makeBoolField(false),
@@ -108,7 +110,7 @@ export class SpellData extends foundry.abstract.TypeDataModel {
// Legacy fields (kept for migration; will be removed in a later release) // Legacy fields (kept for migration; will be removed in a later release)
overcasting: makeStringField(''), overcasting: makeStringField(''),
complexityLevel: makeIntField(0, { min: 0, max: 10 }), // `complexityLevel` was removed 2026-10-26: Poziom Skomplikowania = number of Symbols.
}; };
} }
} }
+18 -15
View File
@@ -4,7 +4,8 @@
* *
* Inputs adapt to spell mode: * Inputs adapt to spell mode:
* - standard: mana * - standard: mana
* - sacred: zeal (+ optional Hekate hybrid toggle if witch symbols set) * - sacred: mana (+ optional Hekate hybrid toggle if witch symbols set)
* - all: regular spell / prayer route (sacred defaults to prayer)
* - witch: mana * - witch: mana
* - blood: mana + blood * - blood: mana + blood
* *
@@ -43,7 +44,6 @@ export async function askCastOptions(spell: SpellForDialog, actor: ActorForDialo
const a = actor.system.attributes; const a = actor.system.attributes;
const baseMana = Math.max(0, spell.system.manaCost ?? 0); const baseMana = Math.max(0, spell.system.manaCost ?? 0);
const baseBlood = Math.max(0, spell.system.bloodCost ?? 0); const baseBlood = Math.max(0, spell.system.bloodCost ?? 0);
const baseZeal = mode === 'sacred' ? 1 : 0;
const showHekate = mode === 'sacred' && (spell.system.components?.symbols?.length ?? 0) > 0; const showHekate = mode === 'sacred' && (spell.system.components?.symbols?.length ?? 0) > 0;
const showSuper = !!spell.system.isSuperspell; const showSuper = !!spell.system.isSuperspell;
@@ -52,7 +52,7 @@ export async function askCastOptions(spell: SpellForDialog, actor: ActorForDialo
const fields: string[] = []; const fields: string[] = [];
if (mode === 'standard' || mode === 'witch' || mode === 'blood') { if (mode === 'standard' || mode === 'witch' || mode === 'blood' || mode === 'sacred') {
const max = a.mana.value; const max = a.mana.value;
fields.push(` fields.push(`
<div class="form-group"> <div class="form-group">
@@ -61,13 +61,6 @@ export async function askCastOptions(spell: SpellForDialog, actor: ActorForDialo
<small>${game.i18n.localize('HBM.resources.maxManaPerSpell')}: ${a.mana.maxPerSpell}</small> <small>${game.i18n.localize('HBM.resources.maxManaPerSpell')}: ${a.mana.maxPerSpell}</small>
</div>`); </div>`);
} }
if (mode === 'sacred') {
fields.push(`
<div class="form-group">
<label>${game.i18n.localize('HBM.spellCast.zealSpent')} (max ${a.zeal.value})</label>
<input type="number" name="zealSpent" value="${baseZeal}" min="1" max="${a.zeal.value}"/>
</div>`);
}
if (mode === 'blood' && a.blood) { if (mode === 'blood' && a.blood) {
const max = a.blood.value; const max = a.blood.value;
fields.push(` fields.push(`
@@ -76,12 +69,21 @@ export async function askCastOptions(spell: SpellForDialog, actor: ActorForDialo
<input type="number" name="bloodSpent" value="${baseBlood}" min="${baseBlood}" max="${max}"/> <input type="number" name="bloodSpent" value="${baseBlood}" min="${baseBlood}" max="${max}"/>
</div>`); </div>`);
} }
if (mode !== 'sacred') {
fields.push(` fields.push(`
<div class="form-group"> <div class="form-group">
<label><input type="checkbox" name="castAsPrayer"/> ${game.i18n.localize('HBM.spell.castingMode.prayer')} (+1 ${game.i18n.localize('HBM.resources.zealAbbr')}, ${game.i18n.localize('HBM.attributes.soul')} + ${game.i18n.localize('HBM.skills.devotion')})</label> <label>${game.i18n.localize('HBM.spellCast.diceModifier')}</label>
<input type="number" name="diceModifier" value="0" step="1"/>
<small>${game.i18n.localize('HBM.spellCast.diceModifierHint')}</small>
</div>`);
// Every known spell can be cast as a regular spell or as a prayer; sacred spells default to a prayer.
fields.push(`
<div class="form-group">
<label>${game.i18n.localize('HBM.spellCast.route')}</label>
<select name="castRoute">
<option value="spell" ${mode === 'sacred' ? '' : 'selected'}>${game.i18n.localize('HBM.spellCast.routeSpell')}</option>
<option value="prayer" ${mode === 'sacred' ? 'selected' : ''}>${game.i18n.localize('HBM.spell.castingMode.prayer')} (+1 ${game.i18n.localize('HBM.resources.zealAbbr')}, ${game.i18n.localize('HBM.attributes.soul')} + ${game.i18n.localize('HBM.skills.devotion')})</option>
</select>
</div>`); </div>`);
}
if (showHekate) { if (showHekate) {
fields.push(` fields.push(`
<div class="form-group"> <div class="form-group">
@@ -160,10 +162,11 @@ export async function askCastOptions(spell: SpellForDialog, actor: ActorForDialo
} }
opts.manaSpent = Math.max(baseMana, spent); opts.manaSpent = Math.max(baseMana, spent);
} }
if (fd.zealSpent != null) opts.zealSpent = Math.max(1, Number(fd.zealSpent) || 1);
if (fd.bloodSpent != null) opts.bloodSpent = Math.max(baseBlood, Number(fd.bloodSpent) || baseBlood); if (fd.bloodSpent != null) opts.bloodSpent = Math.max(baseBlood, Number(fd.bloodSpent) || baseBlood);
const diceMod = Math.trunc(Number(fd.diceModifier) || 0);
if (diceMod !== 0) opts.diceModifier = diceMod;
if (fd.hekateMode) opts.hekateMode = 'witch'; if (fd.hekateMode) opts.hekateMode = 'witch';
if (fd.castAsPrayer) opts.castAsPrayer = true; opts.castAsPrayer = fd.castRoute === 'prayer';
if (fd.bypassSuperspellWarning) opts.bypassSuperspellWarning = true; if (fd.bypassSuperspellWarning) opts.bypassSuperspellWarning = true;
if (fd.bypassNonCombatBlock) opts.bypassNonCombatBlock = true; if (fd.bypassNonCombatBlock) opts.bypassNonCombatBlock = true;
const groupRaw = String(fd.groupCasters ?? '').trim(); const groupRaw = String(fd.groupCasters ?? '').trim();
+2
View File
@@ -30,6 +30,7 @@ import { castSpell } from './logic/spell-cast';
import * as bloodMagic from './logic/blood-magic'; import * as bloodMagic from './logic/blood-magic';
import * as abyssMagic from './logic/abyss-magic'; import * as abyssMagic from './logic/abyss-magic';
import * as brewing from './logic/brewing'; import * as brewing from './logic/brewing';
import * as runicArmor from './logic/runic-armor';
import { rest } from './logic/rest'; import { rest } from './logic/rest';
import * as trade from './logic/trade'; import * as trade from './logic/trade';
@@ -137,6 +138,7 @@ Hooks.once('ready', () => {
bloodMagic, bloodMagic,
abyssMagic, abyssMagic,
brewing, brewing,
runicArmor,
rest, rest,
trade, trade,
}; };
+25 -11
View File
@@ -12,12 +12,12 @@
* when present; otherwise falls back to a chat * when present; otherwise falls back to a chat
* prompt for the GM). * prompt for the GM).
* - rollMistrzLosuPenalty(actor) - d100 penalty roll (Klątwa Otchłani VII). * - rollMistrzLosuPenalty(actor) - d100 penalty roll (Klątwa Otchłani VII).
* - addInsanity(actor, n) - increments actor.system.attributes.insanity; * - addInsanity(actor, n) - adds Punkty Obłędu; at Próg Obłędu resets them,
* when crossing a threshold, fires a hook for * rolls the illness k100 and fires `hbm.mentalIllness`.
* the GM to apply a mental condition.
*/ */
import type { CastableActor } from './spell-cast'; import type { CastableActor } from './spell-cast';
import { madnessThreshold } from './rules';
export type AbyssDiscipline = 'aspects' | 'primal'; export type AbyssDiscipline = 'aspects' | 'primal';
@@ -40,19 +40,33 @@ export function dispatchAbyssCast(spell: SpellLikeForAbyss): AbyssDiscipline {
return 'aspects'; return 'aspects';
} }
/** Insanity thresholds (Klątwa Otchłani VIII). Crossing any triggers a mental-condition roll. */ /**
const INSANITY_THRESHOLDS = [3, 6, 10, 15] as const; * Punkty Obłędu (Choroby Psychiczne). On reaching Próg Obłędu (Dusza + 3, Krasnolud +1) the points reset
* to 0 (the excess is lost) and the character gains one illness: k100 against the 50-illness table in the
* vault (`rules/02. Klątwa Otchłani/Rozdział IV - Choroby Psychiczne.md`), re-rolling duplicates. The table
* itself is not copied here, so the roll is posted to chat for the GM and `hbm.mentalIllness` is fired.
*/
export async function addInsanity(actor: CastableActor, n: number): Promise<{ before: number; after: number; thresholdsCrossed: number[] }> { export async function addInsanity(actor: CastableActor, n: number): Promise<{ before: number; after: number; thresholdsCrossed: number[] }> {
if (n <= 0) return { before: 0, after: 0, thresholdsCrossed: [] }; if (n <= 0) return { before: 0, after: 0, thresholdsCrossed: [] };
const before = ((actor.system.attributes as any).insanity ?? 0) as number; const attrs = actor.system.attributes as any;
const before = (attrs.insanity ?? 0) as number;
const after = before + n; const after = before + n;
const raceItem = (actor as any).items?.find?.((i: any) => i.type === 'race');
const raceId = String(raceItem?.system?.raceId ?? raceItem?.name ?? (actor as any).system?.details?.raceId ?? '');
const threshold = madnessThreshold(Number(attrs.soul?.value ?? 0), raceId);
if (after < threshold) {
await actor.update({ 'system.attributes.insanity': after }); await actor.update({ 'system.attributes.insanity': after });
const crossed = INSANITY_THRESHOLDS.filter((t) => before < t && after >= t); return { before, after, thresholdsCrossed: [] };
if (crossed.length > 0) {
Hooks.callAll('hbm.insanityThreshold', actor, crossed, { before, after });
} }
return { before, after, thresholdsCrossed: [...crossed] }; await actor.update({ 'system.attributes.insanity': 0 });
const roll = new Roll('1d100');
await roll.evaluate();
await roll.toMessage({
flavor: `${game.i18n.localize('HBM.insanity.illnessRoll')} - ${actor.name}`,
speaker: ChatMessage.getSpeaker({ actor: actor as unknown as Actor }),
});
Hooks.callAll('hbm.mentalIllness', actor, { before, after, threshold, roll: roll.total });
return { before, after: 0, thresholdsCrossed: [threshold] };
} }
/** Roll d100 against a named table inside the abyss roll-tables pack. */ /** Roll d100 against a named table inside the abyss roll-tables pack. */
+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: * Krew sources and sinks follow the vault (Foundry Sync Brief §17):
* - spendBlood(actor, n) : deduct n from blood pool, fail if insufficient. * - selfHarm Samookaleczenie (Akcja) / Większe Samookaleczenie (Akcja Natychmiastowa)
* - selfHarm(actor, hp) : convert hp → blood at 2:1 ratio (2 HP → 1 Blood). * - useLifeSteal / resolveLifeSteal Kradzież Życia / Większa Kradzież Życia (1 Zapał)
* - lifeStealOnDamage(...) : when actor inflicts damage with a blood spell, * - sacrifice / greatSacrifice Złożenie w Ofierze / Wielka Ofiara (1 Zapał)
* restore blood = floor(damage / 4) (capped at max). * - 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). * 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) already deducts `bloodCost` directly; * The cast pipeline (logic/spell-cast.ts) deducts `bloodCost` directly.
* this module is the reusable API surface for UI dialogs and the damage hook.
*/ */
import { getMagicPowerEntry } from '../constants';
import type { CastableActor } from './spell-cast'; 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 SELF_HARM_THRESHOLD = 2;
const RESPECT_FOR_LIFE_PENALTY = 3; // multiplier when talent present const FLAG_SCOPE = 'hbm-rpg-v3';
const LIFE_STEAL_DIVISOR = 4; // damage / N → blood restored
interface ActorWithItems extends CastableActor { interface ActorWithItems extends CastableActor {
items?: Iterable<{ type: string; system?: { slug?: string } }>; items?: Iterable<{ type: string; system?: { slug?: string } }>;
} }
function hasRespectForLife(actor: ActorWithItems): boolean { export type BloodAction = 'selfHarm' | 'lifeSteal' | 'sacrifice' | 'absorb';
if (!actor.items) return false;
for (const it of actor.items) { /** 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; if (it.type !== 'talent') continue;
const slug = (it as any).system?.slug ?? (it as any).flags?.['hbm-rpg-v3']?.slug; const slug = (it as any).system?.slug ?? (it as any).flags?.[FLAG_SCOPE]?.slug;
if (slug === 'respect-for-life' || slug === 'szacunek-do-zycia') return true; 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; return false;
} }
@@ -53,33 +80,155 @@ export async function spendBlood(actor: CastableActor, amount: number): Promise<
return { ok: true, spent: amount, remaining: blood.value - amount }; return { ok: true, spent: amount, remaining: blood.value - amount };
} }
/** function used(actor: CastableActor, action: BloodAction): number {
* Self-Harm: convert health into blood. Returns blood gained. return Number((actor.system.attributes as any).bloodUsed?.[action]) || 0;
* 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' };
} }
const ratio = hasRespectForLife(actor as ActorWithItems) ? SELF_HARM_RATIO * RESPECT_FOR_LIFE_PENALTY : SELF_HARM_RATIO;
const gained = Math.floor(hpSpent / ratio); function bump(action: BloodAction, actor: CastableActor): Record<string, number> {
if (gained <= 0) return { ok: false, bloodGained: 0, reason: 'ratio-too-low' }; return { [`system.attributes.bloodUsed.${action}`]: used(actor, action) + 1 };
const newBlood = Math.min(blood.max, blood.value + gained); }
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({ await actor.update({
'system.attributes.health.value': health.value - hpSpent, 'system.attributes.health.value': (a.health?.value ?? 0) - hpLost,
'system.attributes.blood.value': newBlood, '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); * Kradzież Życia: 1 Zapał, free Akcja Natychmiastowa (outside the normal limit), once per round
* Większe Wchłanianie Krwi: 3 HP per Krew (Akcja Natychmiastowa), or 1 Zapał per 5 Krew. * (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( export async function absorbBlood(
actor: CastableActor, actor: CastableActor,
@@ -87,8 +236,9 @@ export async function absorbBlood(
opts: { greater?: boolean; asZeal?: boolean } = {}, opts: { greater?: boolean; asZeal?: boolean } = {},
): Promise<{ ok: boolean; hpGained: number; zealGained: number; reason?: string }> { ): Promise<{ ok: boolean; hpGained: number; zealGained: number; reason?: string }> {
const a = actor.system.attributes as any; 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 (!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 }; const update: Record<string, number> = { 'system.attributes.blood.value': a.blood.value - bloodSpent };
let hpGained = 0; let hpGained = 0;
let zealGained = 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))); 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; update['system.attributes.health.value'] = (a.health?.value ?? 0) + hpGained;
} }
if (!opts.greater) Object.assign(update, bump('absorb', actor));
await actor.update(update); await actor.update(update);
return { ok: true, hpGained, zealGained }; 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> { * Passive Krew (talents): 1 per 4 HP lost (Szacunek do Życia) / 1 per 2 HP (Krwisty Pojedynkowicz),
if (damageDealt <= 0) return 0; * by either side, only from slashing or piercing weapons. Call for the victim and the attacker.
const blood = actor.system.attributes.blood; */
export async function gainKrewFromWeaponDamage(actor: CastableActor, hpLost: number): Promise<number> {
const blood = (actor.system.attributes as any)?.blood;
if (!blood) return 0; if (!blood) return 0;
const restore = Math.floor(damageDealt / LIFE_STEAL_DIVISOR); const gained = addKrew(blood, krewFromWeaponHpLoss(hpLost, krewTalent(actor)));
if (restore <= 0) return 0; if (gained > 0) await actor.update({ 'system.attributes.blood.value': blood.value + gained });
const newBlood = Math.min(blood.max, blood.value + restore); return gained;
await actor.update({ 'system.attributes.blood.value': newBlood }); }
return newBlood - blood.value;
/** 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 { applyDamage } from './damage';
import type { SpellDamageProfile } from './rules';
import { CONDITIONS } from './conditions'; import { CONDITIONS } from './conditions';
declare const Hooks: any; declare const Hooks: any;
@@ -71,13 +72,20 @@ async function onApplyDamage(btn: HTMLElement): Promise<void> {
return; return;
} }
const ignoresArmor = btn.dataset.ignoresArmor === 'true'; 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, { await applyDamage(target, {
amount, amount,
type: (btn.dataset.type as any) ?? 'magical', type: (btn.dataset.type as any) ?? 'magical',
ignoreMagicalArmor: ignoresArmor, // A spell-specific profile replaces the blanket "ignores all armour" flag.
ignoreMagicalShield: ignoresArmor, ignoreMagicalArmor: profile ? !!profile.ignoreMagicalArmor : ignoresArmor,
ignorePhysicalArmor: 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', minDamageAfterArmor: btn.dataset.minAfterArmor === 'true',
fire: btn.dataset.fire === 'true',
} as any); } as any);
} }
+28
View File
@@ -7,6 +7,8 @@
*/ */
import { applyDamage } from './damage'; import { applyDamage } from './damage';
import { startOfFightKrew, endOfFightKrew } from './blood-magic';
import { restoreAllRunicArmor } from './runic-armor';
function hasStatus(actor: any, id: string): boolean { function hasStatus(actor: any, id: string): boolean {
const effects = actor?.effects ?? []; const effects = actor?.effects ?? [];
@@ -55,6 +57,15 @@ export function registerCombatHooks(): void {
if (actor?.system?.attributes?.immediateActionsUsed) { if (actor?.system?.attributes?.immediateActionsUsed) {
await actor.update({ 'system.attributes.immediateActionsUsed': 0 }); 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({ ChatMessage.create({
content: `<em>${game.i18n.localize('HBM.combat.newRound')}</em>`, 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) => { Hooks.on('combatTurn', async (combat: Combat) => {
// The previous combatant's turn just ended: expire their timed Stany. // The previous combatant's turn just ended: expire their timed Stany.
const prevId = (combat as any).previous?.combatantId; const prevId = (combat as any).previous?.combatantId;
+61 -15
View File
@@ -1,6 +1,6 @@
/** /**
* Damage application engine - flows through 4 layers: * 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) * 2. Magical Shield (raw temp HP; cannot be healed; vanishes at 0)
* 3. Physical Armor (flat DR; optionally degrade `condition` by 1) * 3. Physical Armor (flat DR; optionally degrade `condition` by 1)
* 4. Health (the leftover hits HP) * 4. Health (the leftover hits HP)
@@ -14,7 +14,8 @@
*/ */
import { DamageType } from '../constants'; 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 { export interface ApplyDamageOptions {
amount: number; amount: number;
@@ -25,6 +26,21 @@ export interface ApplyDamageOptions {
damageArmor?: boolean; damageArmor?: boolean;
/** Magiczny Pocisk: at least 1 damage gets through Pancerz to Żywotność. */ /** Magiczny Pocisk: at least 1 damage gets through Pancerz to Żywotność. */
minDamageAfterArmor?: boolean; 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; postChat?: boolean;
} }
@@ -34,6 +50,12 @@ export interface DamageReport {
hpDamage: number; hpDamage: number;
shieldDropped: boolean; shieldDropped: boolean;
oszolomionyApplied: 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. */ /** Dusza (Determinacja) 4:S for a concentrating target; absent when no Żywotność was lost. */
concentrationTest?: { threshold: number; successes: number }; concentrationTest?: { threshold: number; successes: number };
} }
@@ -48,7 +70,8 @@ interface ActorLike {
} }
export async function applyDamage(actor: ActorLike, opts: ApplyDamageOptions): Promise<DamageReport> { export async function applyDamage(actor: ActorLike, opts: ApplyDamageOptions): Promise<DamageReport> {
const total = Math.max(0, Math.floor(opts.amount)); let total = Math.max(0, Math.floor(opts.amount));
if (opts.fire && actorHasTalent(actor as any, 'Hartowany w Ogniu')) total = hardenedFireDamage(total);
const a = actor.system.attributes; const a = actor.system.attributes;
const updates: Record<string, unknown> = {}; const updates: Record<string, unknown> = {};
const report: DamageReport = { const report: DamageReport = {
@@ -61,30 +84,45 @@ export async function applyDamage(actor: ActorLike, opts: ApplyDamageOptions): P
let remaining = total; 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) { if (!opts.ignoreMagicalArmor && a.magicalArmor) {
const dr = Math.max(0, Number(a.magicalArmor.value) || 0); const dr = Math.max(0, Number(a.magicalArmor.value) || 0);
const absorbed = Math.min(dr, remaining); const absorbed = Math.min(dr, remaining);
if (absorbed > 0) { if (absorbed > 0) {
const incomingDmg = remaining; const incoming = remaining;
report.absorbed.magicalArmor = absorbed; report.absorbed.magicalArmor = absorbed;
remaining -= absorbed; remaining -= absorbed;
const newCounter = (Number(a.magicalArmor.runicCounter) || 0) + incomingDmg; const disabled = opts.destroyMagicalDefenses
const decrements = Math.floor(newCounter / 5); ? dr
const newValue = Math.max(0, dr - decrements); : runicArmorPointsDisabled(opts.runicArmorStep ? incoming : remaining, opts.runicArmorStep);
updates['system.attributes.magicalArmor.runicCounter'] = newCounter % 5; 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; 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) { if (remaining > 0 && !opts.ignoreMagicalShield && a.magicalShield) {
const shield = Math.max(0, Number(a.magicalShield.value) || 0); const shield = Math.max(0, Number(a.magicalShield.value) || 0);
const taken = Math.min(shield, remaining); const mult = Math.max(1, opts.shieldMultiplier ?? 1);
if (taken > 0) { const effective = Math.min(shield, remaining * mult);
report.absorbed.magicalShield = taken; if (effective > 0) {
remaining -= taken; const consumed = Math.ceil(effective / mult);
const newShield = shield - taken; report.absorbed.magicalShield = consumed;
remaining -= consumed;
const newShield = opts.destroyMagicalDefenses ? 0 : shield - effective;
updates['system.attributes.magicalShield.value'] = newShield; updates['system.attributes.magicalShield.value'] = newShield;
if (newShield === 0) report.shieldDropped = true; 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); 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. // Oszołomiony: a single hit that costs ≥ half max Żywotność after all reductions.
if (triggersStun(report.hpDamage, Number(a.health?.max) || 0) && actor.toggleStatusEffect) { if (triggersStun(report.hpDamage, Number(a.health?.max) || 0) && actor.toggleStatusEffect) {
const already = actor.effects?.find((e: any) => e.statuses?.has?.('oszolomiony')); 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); update['system.attributes.elixirTolerance'] = Math.max(0, toleranceBefore - 1);
toleranceRecovered = 1; toleranceRecovered = 1;
} }
if (a.magicalArmor?.runicCounter && a.magicalArmor.runicCounter > 0) { // Disabled Runiczny Pancerz points come back (only Rozerwanie Run is permanent).
update['system.attributes.magicalArmor.runicCounter'] = 0; 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 * Rounding convention: everything fractional rounds UP, except
* - new-spell mana cost (nearest, min 1), * - new-spell mana cost (nearest, min 1),
* - Nadczarowanie bonus from level 3 (halved, DOWN, min 1), * - Nadczarowanie bonus from level 3 (halved, DOWN, min 1).
* - fire damage with Hartowany w Ogniu (halved, DOWN). * (Hartowany w Ogniu is a flat -2 per fire hit since the 2026-10-26 sync, no rounding.)
*/ */
/** Odetchnięcie: at most twice between two Długie Odpoczynki. */ /** Odetchnięcie: at most twice between two Długie Odpoczynki. */
@@ -133,6 +133,195 @@ export function bloodToZeal(blood: number): number {
return Math.floor(Math.max(0, blood) / 5); return Math.floor(Math.max(0, blood) / 5);
} }
// ---------------------------------------------------------------------------
// 2026-10-26 sync
// ---------------------------------------------------------------------------
export interface WornArmorPiece {
/** `armor.armorType` of an equipped gear item. */
armorType: string;
/** Special-case item that never hampers casting (Średnia zbroja kultysty, Napierśnik Ninurty). */
ignoresCastingPenalty?: boolean;
}
export interface CastingArmorPenalty {
/** Dice removed from the Magia test (positive number). */
dice: number;
/** Extra Mana per spell, also when overcasting. */
mana: number;
}
/**
* Zbroja a rzucanie zaklęć: Średni -1 die; Ciężki -3 dice and +2 Mana; Tarcza -1 die (stacks).
* Pancerz Wiary removes the armour penalties (dice AND Mana) but never the shield penalty.
* Runiczny Pancerz and Magiczne Osłony are not gear and never carry a penalty.
*/
export function castingArmorPenalty(worn: WornArmorPiece[], faithArmor = false): CastingArmorPenalty {
let dice = 0;
let mana = 0;
for (const piece of worn) {
if (piece.ignoresCastingPenalty) continue;
if (piece.armorType === 'shield') dice += 1;
else if (faithArmor) continue;
else if (piece.armorType === 'medium') dice += 1;
else if (piece.armorType === 'heavy') { dice += 3; mana += 2; }
}
return { dice, mana };
}
/** Does the actor own a talent item with this id / name? (case-insensitive) */
export function actorHasTalent(actor: unknown, talentId: string): boolean {
const id = talentId.toLowerCase();
const items = (actor as any)?.items as Iterable<any> | undefined;
if (!items) return false;
for (const it of items) {
if (it.type !== 'talent') continue;
const t = String(it.system?.talentId ?? it.system?.id ?? it.name ?? '').toLowerCase();
if (t === id) return true;
}
return false;
}
/** Prayer or regular spell? Sacred spells default to a prayer; everything else defaults to a regular spell. */
export function resolveCastAsPrayer(
spell: { system: { castingMode?: string } },
opts: { castAsPrayer?: boolean; hekateMode?: string },
): boolean {
if (opts.castAsPrayer != null) return opts.castAsPrayer;
return (spell.system.castingMode ?? 'standard') === 'sacred' && opts.hekateMode !== 'witch';
}
/** Hartowany w Ogniu: each fire hit deals 2 less (min 0). */
export function hardenedFireDamage(amount: number): number {
return Math.max(0, Math.trunc(amount) - 2);
}
/** Próg Obłędu: Dusza + 3 for everyone, Krasnolud +1. */
export function madnessThreshold(soul: number, raceId = ''): number {
return Math.max(0, Math.trunc(soul)) + 3 + (raceId.toLowerCase() === 'dwarf' || raceId.toLowerCase() === 'krasnolud' ? 1 : 0);
}
/** Golem limit: floor(Umysł / 2), min 1 (spell golems count too; two straw golems count as one). */
export function golemLimit(mind: number): number {
return Math.max(1, Math.floor(Math.max(0, mind) / 2));
}
/** Upadek: 1 per full metre above 2 m, cap 20; Ciało (Atletyka) 4:1 halves it (rounded down). */
export function fallDamage(metres: number, halved = false): number {
const raw = Math.min(20, Math.max(0, Math.floor(metres) - 2));
return halved ? Math.floor(raw / 2) : raw;
}
/** A fall of 4 m or more knocks the target Przewrócony unless the landing was cushioned. */
export const fallKnocksProne = (metres: number, cushioned = false) => !cushioned && metres >= 4;
/** Test Przeżycia (Umierający): tier by damage past the death threshold; each hit while dying raises S's T by 1. */
export function survivalTest(damagePastThreshold: number, hitsWhileDying = 0): { threshold: number; successes: number } {
const successes = damagePastThreshold <= 2 ? 1 : damagePastThreshold <= 5 ? 2 : 3;
return { threshold: 4 + Math.max(0, hitsWhileDying), successes };
}
/** Pełny Unik: Unik tests are one Stopień easier until the start of the next turn. */
export const FULL_DODGE_THRESHOLD_DELTA = -1;
/**
* Zapał regen at the start of a turn is blocked when since the start of the previous turn the character
* took a PAID extra Action or a PAID extra Immediate Action (beyond the free one). Zapał spent on prayers
* or Talents with their own Zapał cost does not count; Eliksir Szybkości's extra Action is free.
*/
export function zealRegenBlocked(paidExtraActions: number, paidExtraImmediateActions: number): boolean {
return paidExtraActions > 0 || paidExtraImmediateActions > 0;
}
/** Spells of category Obrażenia: +1 damage once, after multipliers/division; not for periodic/overcast/curses/artifacts. */
export const DAMAGE_CATEGORY_BONUS = 1;
/**
* Runiczny Pancerz – points disabled by ONE hit (hits are never added together).
* 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. */ /** 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_NORMAL = 3;
export const MANA_ELIXIR_QUICK_DIE = 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 { export function fearThresholdMod(sourceId: string | undefined, targetId: string | undefined): number {
return sourceId && targetId && sourceId === targetId ? 1 : 0; return sourceId && targetId && sourceId === targetId ? 1 : 0;
} }
/** Casting test pool with the player's +/- dice modifier (whole dice, never below 0). */
export function applyDiceModifier(pool: number, modifier?: number): number {
return Math.max(0, Math.trunc(Number(pool) || 0) + Math.trunc(Number(modifier) || 0));
}
+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 { HbmTSRoll } from '../dice/ts-roll';
import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD, getMagicPowerEntry } from '../constants'; import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD, getMagicPowerEntry } from '../constants';
import { manaToBorrow, exhaustionDice, manaExhaustionTier, overLimitTest } from './rules'; import { manaToBorrow, exhaustionDice, manaExhaustionTier, overLimitTest, applyDiceModifier, castingArmorPenalty, actorHasTalent, resolveCastAsPrayer, SPELL_DAMAGE_PROFILES } from './rules';
import type { CastingArmorPenalty } from './rules';
import { validateCast } from './spell-validation'; import { validateCast } from './spell-validation';
import type { ValidationResult } from './spell-validation'; import type { ValidationResult } from './spell-validation';
import { rollSpellDamage } from './spell-damage'; import { rollSpellDamage } from './spell-damage';
@@ -49,12 +50,13 @@ export interface SpellLike {
deity?: string; deity?: string;
manaCost: number; manaCost: number;
bloodCost?: number; bloodCost?: number;
complexityLevel: number;
isSuperspell?: boolean; isSuperspell?: boolean;
requiresGroupCast?: boolean; requiresGroupCast?: boolean;
minCasters?: number; minCasters?: number;
nonCombatOnly?: boolean; nonCombatOnly?: boolean;
damageBase?: string; damageBase?: string;
/** Spell category is Obrażenia (+1 damage). Not carried by vault frontmatter yet; set per spell. */
damageCategory?: boolean;
damageType?: string; damageType?: string;
ignoresArmor?: boolean; ignoresArmor?: boolean;
statusEffects?: string[]; statusEffects?: string[];
@@ -72,10 +74,17 @@ export interface CastOptions {
zealSpent?: number; zealSpent?: number;
bloodSpent?: number; bloodSpent?: number;
hekateMode?: 'sacred' | 'witch'; hekateMode?: 'sacred' | 'witch';
/**
* Cast as a prayer (Dusza + Oddanie, Mana + 1 Zapał, cannot be dispelled) instead of a regular spell
* (Magia + Zdolności Magiczne, Mana only, can be dispelled). Every known spell may be cast either way;
* for `castingMode: sacred` spells an unset value means prayer (the usual route for a priest).
*/
castAsPrayer?: boolean; castAsPrayer?: boolean;
bypassSuperspellWarning?: boolean; bypassSuperspellWarning?: boolean;
bypassNonCombatBlock?: boolean; bypassNonCombatBlock?: boolean;
groupCasters?: string[]; groupCasters?: string[];
/** Modifier to the number of dice in the casting test (+/-, pool never below 0). */
diceModifier?: number;
/** Override required successes (used by variableSuccesses summons). */ /** Override required successes (used by variableSuccesses summons). */
requiredOverride?: number; requiredOverride?: number;
speaker?: ChatMessage.SpeakerData; speaker?: ChatMessage.SpeakerData;
@@ -109,7 +118,22 @@ async function recordRoll(rolls: CastRollEntry[], roll: HbmTSRoll, label: string
} }
} }
export async function castSpell(actor: CastableActor, spell: SpellLike, opts: CastOptions = {}): Promise<CastResult> { /** Equipped armour / shield worn by the actor, as the casting-penalty helper wants it. */
export function castingPenaltyFor(actor: CastableActor): CastingArmorPenalty {
const items = (actor as any).items as Iterable<any> | undefined;
const worn: Array<{ armorType: string; ignoresCastingPenalty?: boolean }> = [];
for (const it of items ?? []) {
if (it.type !== 'gear' || !it.system?.equipped || it.system?.category !== 'armor') continue;
worn.push({
armorType: String(it.system.armor?.armorType ?? 'light'),
ignoresCastingPenalty: !!it.system.armor?.ignoresCastingPenalty,
});
}
return castingArmorPenalty(worn, actorHasTalent(actor, 'Pancerz Wiary'));
}
export async function castSpell(actor: CastableActor, spell: SpellLike, rawOpts: CastOptions = {}): Promise<CastResult> {
const opts: CastOptions = { ...rawOpts, castAsPrayer: resolveCastAsPrayer(spell, rawOpts) };
// 1. Validate // 1. Validate
const validation = validateCast(actor, spell, opts); const validation = validateCast(actor, spell, opts);
if (!validation.ok) { if (!validation.ok) {
@@ -127,9 +151,10 @@ export async function castSpell(actor: CastableActor, spell: SpellLike, opts: Ca
let baseRoll: HbmTSRoll | null = null; let baseRoll: HbmTSRoll | null = null;
const rolls: CastRollEntry[] = []; const rolls: CastRollEntry[] = [];
if (mode === 'sacred') { if (mode === 'sacred') {
// Sacred spells are cast like any other spell: as a prayer (default) or as a regular spell.
baseRoll = opts.hekateMode === 'witch' baseRoll = opts.hekateMode === 'witch'
? await castWitch(actor, spell, opts, rolls) ? await castWitch(actor, spell, opts, rolls)
: await castSacred(actor, spell, opts, rolls); : await castStandard(actor, spell, opts, rolls);
} else if (mode === 'witch') { } else if (mode === 'witch') {
baseRoll = await castWitch(actor, spell, opts, rolls); baseRoll = await castWitch(actor, spell, opts, rolls);
} else if (mode === 'blood') { } else if (mode === 'blood') {
@@ -144,7 +169,11 @@ export async function castSpell(actor: CastableActor, spell: SpellLike, opts: Ca
if (baseRoll?.ts?.isSuccess) { if (baseRoll?.ts?.isSuccess) {
// 4. Damage // 4. Damage
if (spell.system.damageBase) { if (spell.system.damageBase) {
damageRoll = await rollSpellDamage(spell.system.damageBase, { actor, spellName: spell.name }); damageRoll = await rollSpellDamage(spell.system.damageBase, {
actor,
spellName: spell.name,
categoryBonus: !!spell.system.damageCategory,
});
} }
// 6. Triggers // 6. Triggers
@@ -198,8 +227,15 @@ async function renderCastCard(
})), })),
type: spell.system.damageType ?? 'magical', type: spell.system.damageType ?? 'magical',
ignoresArmor: !!spell.system.ignoresArmor, ignoresArmor: !!spell.system.ignoresArmor,
// Fire damage: Hartowany w Ogniu takes 2 off each hit.
fire: spell.system.school === 'elementsFire',
// Magiczny Pocisk: at least 1 damage reaches Żywotność after Pancerz. // Magiczny Pocisk: at least 1 damage reaches Żywotność after Pancerz.
minAfterArmor: ((spell as any).flags?.['hbm-rpg-v3']?.slug ?? (spell.system as any).slug) === 'magic-missile', minAfterArmor: ((spell as any).flags?.['hbm-rpg-v3']?.slug ?? (spell.system as any).slug) === 'magic-missile',
// Shield-breakers / Szkarłatny Pocisk / Prawdziwa Dezintegracja treat the magical defences specially.
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, 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> { async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOptions, rolls: CastRollEntry[]): Promise<HbmTSRoll | null> {
const baseCost = Math.max(0, spell.system.manaCost ?? 0); const baseCost = Math.max(0, spell.system.manaCost ?? 0);
const manaSpent = Math.max(baseCost, Math.floor(opts.manaSpent ?? baseCost)); const armorPenalty = castingPenaltyFor(actor);
// Ciężki armour: +2 Mana per spell, overcast included.
const manaSpent = Math.max(baseCost, Math.floor(opts.manaSpent ?? baseCost)) + armorPenalty.mana;
const a = actor.system.attributes; const a = actor.system.attributes;
// Casting beyond the remaining daily pool is allowed: the missing mana is // Casting beyond the remaining daily pool is allowed: the missing mana is
@@ -271,9 +309,11 @@ async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOp
const magicDice = typeof a.magic.dicePool === 'number' const magicDice = typeof a.magic.dicePool === 'number'
? a.magic.dicePool ? a.magic.dicePool
: getMagicPowerEntry(a.magic.actual ?? a.magic.value ?? 0).dicePool; : getMagicPowerEntry(a.magic.actual ?? a.magic.value ?? 0).dicePool;
const pool = opts.castAsPrayer // Armour and shield penalties hit the Magia test only (a prayer rolls Dusza + Oddanie).
const basePool = opts.castAsPrayer
? a.soul.value + (actor.system.skills?.devotion?.value ?? 0) ? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0); : magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0) - armorPenalty.dice;
const pool = applyDiceModifier(basePool, opts.diceModifier);
const flavor = opts.castAsPrayer const flavor = opts.castAsPrayer
? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')}` ? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')}`
: `${spell.name}`; : `${spell.name}`;
@@ -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> { async function castWitch(actor: CastableActor, spell: SpellLike, opts: CastOptions, rolls: CastRollEntry[]): Promise<HbmTSRoll | null> {
const a = actor.system.attributes; const a = actor.system.attributes;
const required = Math.max(0, spell.system.complexityLevel ?? spell.system.components?.symbols?.length ?? 0); // Poziom Skomplikowania = number of Symbols in the spell.
const required = Math.max(0, spell.system.components?.symbols?.length ?? 0);
const maxSymbols = Math.ceil(a.magic.actual / 2); const maxSymbols = Math.ceil(a.magic.actual / 2);
const armorPenalty = castingPenaltyFor(actor);
// Witch magic still has a mana cost. // Witch magic still has a mana cost (+2 in Ciężki armour).
const baseCost = Math.max(0, spell.system.manaCost ?? 0); const baseCost = Math.max(0, spell.system.manaCost ?? 0) + armorPenalty.mana;
const updates: Record<string, any> = { 'system.attributes.mana.value': a.mana.value - baseCost }; const updates: Record<string, any> = { 'system.attributes.mana.value': a.mana.value - baseCost };
if (opts.castAsPrayer) { if (opts.castAsPrayer) {
updates['system.attributes.zeal.value'] = a.zeal.value - 1; updates['system.attributes.zeal.value'] = a.zeal.value - 1;
@@ -374,9 +397,10 @@ async function castWitch(actor: CastableActor, spell: SpellLike, opts: CastOptio
const magicDice = typeof (a.magic as any).dicePool === 'number' const magicDice = typeof (a.magic as any).dicePool === 'number'
? (a.magic as any).dicePool ? (a.magic as any).dicePool
: getMagicPowerEntry((a.magic as any).actual ?? a.magic.value ?? 0).dicePool; : getMagicPowerEntry((a.magic as any).actual ?? a.magic.value ?? 0).dicePool;
const pool = opts.castAsPrayer const basePool = opts.castAsPrayer
? a.soul.value + (actor.system.skills?.devotion?.value ?? 0) ? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0); : magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0) - armorPenalty.dice;
const pool = applyDiceModifier(basePool, opts.diceModifier);
const flavor = opts.castAsPrayer const flavor = opts.castAsPrayer
? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')} (${required}/${maxSymbols})` ? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')} (${required}/${maxSymbols})`
: `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.witch')} (${required}/${maxSymbols})`; : `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.witch')} (${required}/${maxSymbols})`;
@@ -403,8 +427,9 @@ async function castBlood(actor: CastableActor, spell: SpellLike, opts: CastOptio
updates['system.attributes.blood.value'] = Math.max(0, blood.value - bloodSpent); updates['system.attributes.blood.value'] = Math.max(0, blood.value - bloodSpent);
} }
// Mana cost (some blood spells may also have one) // Mana cost (some blood spells may also have one; +2 in Ciężki armour)
const manaCost = Math.max(0, spell.system.manaCost ?? 0); const armorPenalty = castingPenaltyFor(actor);
const manaCost = Math.max(0, spell.system.manaCost ?? 0) + (Math.max(0, spell.system.manaCost ?? 0) > 0 ? armorPenalty.mana : 0);
if (manaCost > 0) { if (manaCost > 0) {
updates['system.attributes.mana.value'] = a.mana.value - manaCost; updates['system.attributes.mana.value'] = a.mana.value - manaCost;
} }
@@ -416,9 +441,10 @@ async function castBlood(actor: CastableActor, spell: SpellLike, opts: CastOptio
const magicDice = typeof (a.magic as any).dicePool === 'number' const magicDice = typeof (a.magic as any).dicePool === 'number'
? (a.magic as any).dicePool ? (a.magic as any).dicePool
: getMagicPowerEntry((a.magic as any).actual ?? a.magic.value ?? 0).dicePool; : getMagicPowerEntry((a.magic as any).actual ?? a.magic.value ?? 0).dicePool;
const pool = opts.castAsPrayer const basePool = opts.castAsPrayer
? a.soul.value + (actor.system.skills?.devotion?.value ?? 0) ? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0); : magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0) - armorPenalty.dice;
const pool = applyDiceModifier(basePool, opts.diceModifier);
const flavor = opts.castAsPrayer const flavor = opts.castAsPrayer
? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')}` ? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')}`
: `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.blood')}`; : `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.blood')}`;
+7 -1
View File
@@ -13,10 +13,16 @@
*/ */
import type { CastableActor } from './spell-cast'; import type { CastableActor } from './spell-cast';
import { DAMAGE_CATEGORY_BONUS } from './rules';
export interface DamageContext { export interface DamageContext {
actor: CastableActor; actor: CastableActor;
spellName?: string; spellName?: string;
/**
* Spell is of category Obrażenia: +1 damage, added once after multipliers/division
* (not for periodic damage, overcast bonuses, other categories, curses or artifacts).
*/
categoryBonus?: boolean;
} }
const ATTR_TOKENS = new Set(['body', 'mind', 'soul', 'magic', 'magicalAbilities', 'devotion']); const ATTR_TOKENS = new Set(['body', 'mind', 'soul', 'magic', 'magicalAbilities', 'devotion']);
@@ -61,7 +67,7 @@ export function buildDamageFormula(spellDamageBase: string, ctx: DamageContext):
return String(v); return String(v);
}); });
return formula; return ctx.categoryBonus ? `(${formula}) + ${DAMAGE_CATEGORY_BONUS}` : formula;
} }
export async function rollSpellDamage(spellDamageBase: string, ctx: DamageContext): Promise<Roll | null> { export async function rollSpellDamage(spellDamageBase: string, ctx: DamageContext): Promise<Roll | null> {
+14 -23
View File
@@ -5,6 +5,7 @@
*/ */
import type { CastableActor, SpellLike, CastOptions } from './spell-cast'; import type { CastableActor, SpellLike, CastOptions } from './spell-cast';
import { resolveCastAsPrayer, actorHasTalent } from './rules';
export interface ValidationIssue { export interface ValidationIssue {
code: string; code: string;
@@ -29,19 +30,7 @@ function getActorRaceId(actor: CastableActor): string {
return String(d.raceId ?? d.race ?? '').toLowerCase(); return String(d.raceId ?? d.race ?? '').toLowerCase();
} }
function actorHasTalent(actor: CastableActor, talentId: string): boolean { export function matchDiscipline(disciplineId: string, targetId: string): boolean {
const id = talentId.toLowerCase();
const items = (actor as any).items as Iterable<any> | undefined;
if (!items) return false;
for (const it of items) {
if (it.type !== 'talent') continue;
const t = String(it.system?.talentId ?? it.system?.id ?? it.name ?? '').toLowerCase();
if (t === id) return true;
}
return false;
}
function matchDiscipline(disciplineId: string, targetId: string): boolean {
const d1 = disciplineId.toLowerCase().trim(); const d1 = disciplineId.toLowerCase().trim();
const d2 = targetId.toLowerCase().trim(); const d2 = targetId.toLowerCase().trim();
if (d1 === d2) return true; if (d1 === d2) return true;
@@ -52,6 +41,10 @@ function matchDiscipline(disciplineId: string, targetId: string): boolean {
abyssprimal: ['abyssprimal', 'magia otchłani', 'primal', 'eldritch', 'eldritch magic', 'abyss - primal', 'magia otchłani - pierwotna magia', 'pierwotna magia'], abyssprimal: ['abyssprimal', 'magia otchłani', 'primal', 'eldritch', 'eldritch magic', 'abyss - primal', 'magia otchłani - pierwotna magia', 'pierwotna magia'],
witch: ['witch', 'wiedźmia magia', 'witch magic', 'dzika wiedźmia magia', 'wildwitch'], witch: ['witch', 'wiedźmia magia', 'witch magic', 'dzika wiedźmia magia', 'wildwitch'],
blood: ['blood', 'magia krwi', 'blood magic'], blood: ['blood', 'magia krwi', 'blood magic'],
// Vault spells say `runic`; the discipline item id is `runes`.
runes: ['runes', 'runic', 'magia runiczna', 'runic magic'],
sacred: ['sacred', 'magia sakralna', 'sacred magic'],
sacredexorcism: ['sacredexorcism', 'magia sakralna - egzorcyzmy', 'egzorcyzmy', 'exorcism', 'exorcisms', 'sacred - exorcisms'],
}; };
let key1: string | null = null; let key1: string | null = null;
@@ -63,6 +56,8 @@ function matchDiscipline(disciplineId: string, targetId: string): boolean {
} }
if (key1 && key2 && key1 === key2) return true; if (key1 && key2 && key1 === key2) return true;
// Egzorcyzmy are a sub-discipline of Magia Sakralna: an exorcist counts as Sacred, not the other way round.
if (key1 === 'sacredexorcism' && key2 === 'sacred') return true;
return false; return false;
} }
@@ -98,22 +93,17 @@ export function validateCast(actor: CastableActor, spell: SpellLike, opts: CastO
const manaSpent = Math.max(baseMana, Math.floor(opts.manaSpent ?? baseMana)); const manaSpent = Math.max(baseMana, Math.floor(opts.manaSpent ?? baseMana));
const bloodSpent = Math.max(0, Math.floor(opts.bloodSpent ?? Number(sys.bloodCost ?? 0))); const bloodSpent = Math.max(0, Math.floor(opts.bloodSpent ?? Number(sys.bloodCost ?? 0)));
if (mode === 'standard' || mode === 'witch' || (mode === 'sacred' && opts.hekateMode === 'witch')) { if (mode === 'standard' || mode === 'witch' || mode === 'sacred') {
if (manaSpent > Number(a.mana?.value ?? 0)) { if (manaSpent > Number(a.mana?.value ?? 0)) {
// Allowed since the 2026-10-20 sync: the shortfall is borrowed (Wyczerpanie Many). // Allowed since the 2026-10-20 sync: the shortfall is borrowed (Wyczerpanie Many).
warnings.push({ code: 'borrow-mana', message: 'Not enough mana - the shortfall will be borrowed (Wyczerpanie Many)', i18nKey: 'HBM.spellCast.borrowMana' }); warnings.push({ code: 'borrow-mana', message: 'Not enough mana - the shortfall will be borrowed (Wyczerpanie Many)', i18nKey: 'HBM.spellCast.borrowMana' });
} }
} }
if (opts.castAsPrayer) { // Prayer (any spell, sacred by default): Mana + 1 Zapał. A regular spell costs no Zapał.
if (1 > Number(a.zeal?.value ?? 0)) { const asPrayer = resolveCastAsPrayer(spell, opts);
if (asPrayer && 1 > Number(a.zeal?.value ?? 0)) {
errors.push({ code: 'no-zeal', message: 'Not enough zeal', i18nKey: 'HBM.spellCast.notEnoughZeal' }); errors.push({ code: 'no-zeal', message: 'Not enough zeal', i18nKey: 'HBM.spellCast.notEnoughZeal' });
} }
} else if (mode === 'sacred' && opts.hekateMode !== 'witch') {
const zealNeeded = Math.max(1, opts.zealSpent ?? 1);
if (zealNeeded > Number(a.zeal?.value ?? 0)) {
errors.push({ code: 'no-zeal', message: 'Not enough zeal', i18nKey: 'HBM.spellCast.notEnoughZeal' });
}
}
if (mode === 'blood') { if (mode === 'blood') {
if (bloodSpent > Number(a.blood?.value ?? 0)) { if (bloodSpent > Number(a.blood?.value ?? 0)) {
errors.push({ code: 'no-blood', message: 'Not enough blood', i18nKey: 'HBM.spellCast.notEnoughBlood' }); errors.push({ code: 'no-blood', message: 'Not enough blood', i18nKey: 'HBM.spellCast.notEnoughBlood' });
@@ -206,7 +196,8 @@ export function validateCast(actor: CastableActor, spell: SpellLike, opts: CastO
// Witch symbol cap // Witch symbol cap
if (mode === 'witch' || opts.hekateMode === 'witch') { if (mode === 'witch' || opts.hekateMode === 'witch') {
const required = Math.max(0, Number(sys.complexityLevel ?? sys.components?.symbols?.length ?? 0)); // Poziom Skomplikowania = number of Symbols in the spell.
const required = Math.max(0, Number(sys.components?.symbols?.length ?? 0));
const maxSymbols = Math.ceil(Number(a.magic?.value ?? 0) / 2); const maxSymbols = Math.ceil(Number(a.magic?.value ?? 0) / 2);
if (required > maxSymbols) { if (required > maxSymbols) {
errors.push({ errors.push({
+2
View File
@@ -9,6 +9,7 @@ import {
AOE_SHAPES, AOE_SHAPES,
SPELL_DAMAGE_TYPES, SPELL_DAMAGE_TYPES,
TRIGGER_EVENTS, TRIGGER_EVENTS,
WOUND_KINDS,
} from '../constants'; } from '../constants';
const { ItemSheetV2 } = foundry.applications.sheets as unknown as { const { ItemSheetV2 } = foundry.applications.sheets as unknown as {
@@ -67,6 +68,7 @@ export class HbmItemSheet extends HandlebarsApplicationMixin(ItemSheetV2) {
itemType: item.type, itemType: item.type,
bodyPartial: TYPE_PARTIAL[item.type] ?? 'systems/hbm-rpg-v3/templates/item/_unknown.hbs', bodyPartial: TYPE_PARTIAL[item.type] ?? 'systems/hbm-rpg-v3/templates/item/_unknown.hbs',
choices: buildChoiceMaps(), choices: buildChoiceMaps(),
woundChoices: Object.fromEntries(WOUND_KINDS.map((k) => [k, `HBM.gear.wound.${k}`])),
effects, effects,
supportsEffects, supportsEffects,
}; };
+5 -30
View File
@@ -1,3 +1,5 @@
import { fetchNbpRate, FALLBACK_RATES } from '../logic/nbp';
const { DialogV2 } = foundry.applications.api as unknown as { const { DialogV2 } = foundry.applications.api as unknown as {
DialogV2: any; DialogV2: any;
}; };
@@ -55,37 +57,10 @@ class MoneyConverterDialog extends DialogV2 {
export async function showMoneyConverter(actor: any): Promise<void> { export async function showMoneyConverter(actor: any): Promise<void> {
const pln = actor.system.details?.money ?? 0; const pln = actor.system.details?.money ?? 0;
const currentYear = actor.system.details?.currentYear ?? 2026; 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> => { const [eurData, usdData] = await Promise.all([fetchNbpRate('eur', currentYear), fetchNbpRate('usd', currentYear)]);
if (year >= 2002 && year <= currentRealYear) { const eurRate = eurData?.rate ?? FALLBACK_RATES.eur!;
for (let day = 1; day <= 7; day++) { const usdRate = usdData?.rate ?? FALLBACK_RATES.usd!;
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 rateDate = eurData?.date ?? usdData?.date ?? 'default'; const rateDate = eurData?.date ?? usdData?.date ?? 'default';
const isLive = !!(eurData || usdData); const isLive = !!(eurData || usdData);
+2 -2
View File
@@ -2,9 +2,9 @@
"id": "hbm-rpg-v3", "id": "hbm-rpg-v3",
"title": "Homebrew Magic: RPG v3", "title": "Homebrew Magic: RPG v3",
"description": "System Foundry VTT dla Homebrew Magic: Role Playing Game v3 - autorska gra fabularna z mechaniką puli kości d6 (TS - Trudność:Sukcesy).", "description": "System Foundry VTT dla Homebrew Magic: Role Playing Game v3 - autorska gra fabularna z mechaniką puli kości d6 (TS - Trudność:Sukcesy).",
"version": "1.4.0", "version": "1.5.2",
"manifest": "https://vtt-content.octoturge.com/packages/system/hbm-rpg-v3.json", "manifest": "https://vtt-content.octoturge.com/packages/system/hbm-rpg-v3.json",
"download": "https://vtt-content.octoturge.com/packages/system/hbm-rpg-v3-v1.4.0.zip", "download": "https://vtt-content.octoturge.com/packages/system/hbm-rpg-v3-v1.5.0.zip",
"compatibility": { "compatibility": {
"minimum": "13", "minimum": "13",
"verified": "14" "verified": "14"
+1 -1
View File
@@ -51,7 +51,7 @@
{{#if damage.targets}} {{#if damage.targets}}
<div class="hbm-card-targets"> <div class="hbm-card-targets">
{{#each damage.targets as |t|}} {{#each damage.targets as |t|}}
<button type="button" class="hbm-action" data-action="hbm-apply-damage" data-target-uuid="{{t.uuid}}" data-amount="{{../damage.total}}" data-type="{{../damage.type}}" data-ignores-armor="{{../damage.ignoresArmor}}" data-min-after-armor="{{../damage.minAfterArmor}}"> <button type="button" class="hbm-action" data-action="hbm-apply-damage" data-target-uuid="{{t.uuid}}" data-amount="{{../damage.total}}" data-type="{{../damage.type}}" data-ignores-armor="{{../damage.ignoresArmor}}" data-min-after-armor="{{../damage.minAfterArmor}}" data-profile="{{../damage.profile}}" data-fire="{{../damage.fire}}">
⚔ {{t.name}} ⚔ {{t.name}}
</button> </button>
{{/each}} {{/each}}
+6
View File
@@ -31,6 +31,11 @@
<option value="environmental" {{#if (eq system.weapon.damageType 'environmental')}}selected{{/if}}>{{localize 'HBM.gear.environmental'}}</option> <option value="environmental" {{#if (eq system.weapon.damageType 'environmental')}}selected{{/if}}>{{localize 'HBM.gear.environmental'}}</option>
</select> </select>
</label> </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> </fieldset>
{{/if}} {{/if}}
@@ -52,6 +57,7 @@
</label> </label>
<label>{{localize 'HBM.gear.strengthRequirement'}}: <input type="number" name="system.armor.strengthRequirement" value="{{system.armor.strengthRequirement}}" min="0"/></label> <label>{{localize 'HBM.gear.strengthRequirement'}}: <input type="number" name="system.armor.strengthRequirement" value="{{system.armor.strengthRequirement}}" min="0"/></label>
<label><input type="checkbox" name="system.armor.stealthDisadvantage" {{#if system.armor.stealthDisadvantage}}checked{{/if}}/> {{localize 'HBM.gear.stealthDisadvantage'}}</label> <label><input type="checkbox" name="system.armor.stealthDisadvantage" {{#if system.armor.stealthDisadvantage}}checked{{/if}}/> {{localize 'HBM.gear.stealthDisadvantage'}}</label>
<label title="{{localize 'HBM.gear.ignoresCastingPenaltyHint'}}"><input type="checkbox" name="system.armor.ignoresCastingPenalty" {{#if system.armor.ignoresCastingPenalty}}checked{{/if}}/> {{localize 'HBM.gear.ignoresCastingPenalty'}}</label>
</fieldset> </fieldset>
{{/if}} {{/if}}
+1
View File
@@ -148,6 +148,7 @@
<label>{{localize 'HBM.spell.damageBase'}}: <label>{{localize 'HBM.spell.damageBase'}}:
<input type="text" name="system.damageBase" value="{{system.damageBase}}" placeholder="np. 1d6+magicalAbilities" /> <input type="text" name="system.damageBase" value="{{system.damageBase}}" placeholder="np. 1d6+magicalAbilities" />
</label> </label>
<label><input type="checkbox" name="system.damageCategory" {{#if system.damageCategory}}checked{{/if}}/> {{localize 'HBM.spell.damageCategory'}}</label>
<label>{{localize 'HBM.spell.damageType'}}: <label>{{localize 'HBM.spell.damageType'}}:
<select name="system.damageType"> <select name="system.damageType">
{{#each @root.choices.damageTypes as |label key|}} {{#each @root.choices.damageTypes as |label key|}}
+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); 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 () => { it('marks shield as dropped when reduced to 0', async () => {
const { actor } = makeActor({ magicalShield: { value: 3 } }); const { actor } = makeActor({ magicalShield: { value: 3 } });
const r = await applyDamage(actor, { amount: 5, postChat: false }); 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 }, zeal: { value: 0, max: 5 },
blood: { value: 0, max: 6 }, blood: { value: 0, max: 6 },
elixirTolerance: 2, elixirTolerance: 2,
magicalArmor: { value: 4, runicCounter: 3 }, magicalArmor: { value: 4, max: 4, runicCounter: 3 },
...overrides.attributes, ...overrides.attributes,
}, },
skills: overrides.skills ?? {}, skills: overrides.skills ?? {},
@@ -94,7 +94,7 @@ describe('rest', () => {
mana: { value: 1, max: 20 }, mana: { value: 1, max: 20 },
zeal: { value: 0, max: 5 }, zeal: { value: 0, max: 5 },
blood: { value: 0, max: 6 }, blood: { value: 0, max: 6 },
magicalArmor: { value: 4, runicCounter: 3 }, magicalArmor: { value: 1, max: 4, runicCounter: 3 },
}, },
}); });
const r = await rest(actor, 'long'); const r = await rest(actor, 'long');
@@ -103,7 +103,7 @@ describe('rest', () => {
expect(updates['system.attributes.mana.value']).toBe(20); expect(updates['system.attributes.mana.value']).toBe(20);
expect(updates['system.attributes.zeal.value']).toBe(5); expect(updates['system.attributes.zeal.value']).toBe(5);
expect(updates['system.attributes.blood.value']).toBe(6); 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(r.toleranceRecovered).toBe(1);
expect(updates['system.attributes.breathersTaken']).toBe(0); expect(updates['system.attributes.breathersTaken']).toBe(0);
expect(updates['system.attributes.manaBorrowed']).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]); 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);
});
});
+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([]);
});
});