Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c3781a2e8 | ||
|
|
9f93745c42 | ||
|
|
582b189ce6 | ||
|
|
5e6dc0698e | ||
|
|
96b2935ff5 | ||
|
|
8d5fd7338a | ||
|
|
a5f2683d15 | ||
|
|
23027cf1b3 | ||
|
|
7b1f6794ad |
@@ -5,6 +5,40 @@ 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.
|
||||||
|
|
||||||
|
## [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
|
## [1.4.1] - 2026-10-20
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
+8
-1
@@ -239,6 +239,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",
|
||||||
@@ -416,7 +423,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)"
|
||||||
|
|||||||
+8
-1
@@ -259,6 +259,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",
|
||||||
@@ -419,7 +426,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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hbm-rpg-v3-foundry",
|
"name": "hbm-rpg-v3-foundry",
|
||||||
"version": "1.4.1",
|
"version": "1.5.2",
|
||||||
"description": "Foundry VTT system for Homebrew Magic: Role Playing Game v3",
|
"description": "Foundry VTT system for Homebrew Magic: Role Playing Game v3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -78,6 +78,15 @@ 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 }),
|
||||||
|
// Blood-magic actions used this round (Samookaleczenie, Kradzież Życia, Złożenie w Ofierze, Wchłanianie Krwi).
|
||||||
|
bloodUsed: new f.SchemaField({
|
||||||
|
selfHarm: makeIntField(0, { min: 0 }),
|
||||||
|
lifeSteal: makeIntField(0, { min: 0 }),
|
||||||
|
sacrifice: makeIntField(0, { min: 0 }),
|
||||||
|
absorb: makeIntField(0, { min: 0 }),
|
||||||
|
}),
|
||||||
|
// Wielka Ofiara used today (once per day; reset by Długi Odpoczynek).
|
||||||
|
greatSacrificeUsed: makeIntField(0, { min: 0, max: 1 }),
|
||||||
// Insanity points (Klątwa Otchłani VIII). Each Abyss exposure may add 1; rolls
|
// Insanity points (Klątwa Otchłani VIII). Each Abyss exposure may add 1; rolls
|
||||||
// mental-condition table when reaching thresholds.
|
// mental-condition table when reaching thresholds.
|
||||||
insanity: makeIntField(0, { min: 0 }),
|
insanity: makeIntField(0, { min: 0 }),
|
||||||
@@ -152,6 +161,8 @@ 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;
|
||||||
magicalArmor: { value: number; max: number; runicCounter: number };
|
magicalArmor: { value: number; max: number; runicCounter: number };
|
||||||
magicalShield: { value: number };
|
magicalShield: { value: number };
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
+221
-49
@@ -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 {
|
||||||
|
return Number((actor.system.attributes as any).bloodUsed?.[action]) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bump(action: BloodAction, actor: CastableActor): Record<string, number> {
|
||||||
|
return { [`system.attributes.bloodUsed.${action}`]: used(actor, action) + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function addKrew(blood: { value: number; max: number }, amount: number): number {
|
||||||
|
return Math.max(0, Math.min(blood.max, blood.value + amount) - blood.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Magia dice + Walka Wręcz skill, threshold `threshold` – returns the Sukcesy. */
|
||||||
|
export async function rollMagicMelee(actor: CastableActor, threshold: number): Promise<number> {
|
||||||
|
const attrs = actor.system.attributes as any;
|
||||||
|
const magic = attrs.magic ?? {};
|
||||||
|
const dice = typeof magic.dicePool === 'number'
|
||||||
|
? magic.dicePool
|
||||||
|
: getMagicPowerEntry(magic.actual ?? magic.value ?? 0).dicePool;
|
||||||
|
const skill = (actor.system.skills as any)?.melee?.value ?? 0;
|
||||||
|
const { HbmTSRoll } = await import('../dice/ts-roll'); // lazy: needs Foundry's Roll at runtime only
|
||||||
|
const roll = HbmTSRoll.fromParams({ pool: dice + skill, threshold, required: 1, flavor: 'Magia (Walka Wręcz)' });
|
||||||
|
await roll.evaluate();
|
||||||
|
await roll.toMessage({ flavor: 'Magia (Walka Wręcz)' });
|
||||||
|
return roll.ts?.successes ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SuccessRoller = (actor: CastableActor, threshold: number) => Promise<number>;
|
||||||
|
|
||||||
|
export interface BloodActionResult {
|
||||||
|
ok: boolean;
|
||||||
|
successes?: number;
|
||||||
|
hpLost?: number;
|
||||||
|
hpGained?: number;
|
||||||
|
krewGained: number;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fail = (reason: string): BloodActionResult => ({ ok: false, krewGained: 0, reason });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Self-Harm: convert health into blood. Returns blood gained.
|
* Samookaleczenie (Akcja, once per round): Test Magia (Walka Wręcz) T=2; lose HP = Sukcesy,
|
||||||
* Default ratio 2 HP → 1 Blood; with `Szacunek do Życia` talent → 6 HP → 1 Blood.
|
* gain 1 Krew per HP. Większe Samookaleczenie (Krwisty Pojedynkowicz, Akcja Natychmiastowa): 2 Krew per HP.
|
||||||
*/
|
*/
|
||||||
export async function selfHarm(actor: CastableActor, hpSpent: number): Promise<{ ok: boolean; bloodGained: number; reason?: string }> {
|
export async function selfHarm(
|
||||||
if (hpSpent <= 0) return { ok: true, bloodGained: 0 };
|
actor: CastableActor,
|
||||||
const a = actor.system.attributes;
|
opts: { greater?: boolean; roller?: SuccessRoller } = {},
|
||||||
const blood = a.blood;
|
): Promise<BloodActionResult> {
|
||||||
if (!blood) return { ok: false, bloodGained: 0, reason: 'no-blood-pool' };
|
const a = actor.system.attributes as any;
|
||||||
const health = (a as any).health as { value: number; max: number } | undefined;
|
if (!a.blood) return fail('no-blood-pool');
|
||||||
if (!health || health.value < hpSpent) {
|
if (opts.greater && !hasTalent(actor, 'wieksze-samookaleczenie', 'krwisty-pojedynkowicz')) return fail('missing-talent');
|
||||||
return { ok: false, bloodGained: 0, reason: 'insufficient-health' };
|
if (used(actor, 'selfHarm') >= 1) return fail('already-used');
|
||||||
}
|
const successes = await (opts.roller ?? rollMagicMelee)(actor, SELF_HARM_THRESHOLD);
|
||||||
const ratio = hasRespectForLife(actor as ActorWithItems) ? SELF_HARM_RATIO * RESPECT_FOR_LIFE_PENALTY : SELF_HARM_RATIO;
|
const hpLost = Math.min(successes, Math.max(0, a.health?.value ?? 0));
|
||||||
const gained = Math.floor(hpSpent / ratio);
|
const krewGained = addKrew(a.blood, krewFromSelfHarm(hpLost, !!opts.greater));
|
||||||
if (gained <= 0) return { ok: false, bloodGained: 0, reason: 'ratio-too-low' };
|
|
||||||
const newBlood = Math.min(blood.max, blood.value + gained);
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -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,12 +72,18 @@ 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',
|
||||||
} as any);
|
} as any);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+57
-14
@@ -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 } from './rules';
|
||||||
|
import { gainKrewFromWeaponDamage } from './blood-magic';
|
||||||
|
|
||||||
export interface ApplyDamageOptions {
|
export interface ApplyDamageOptions {
|
||||||
amount: number;
|
amount: number;
|
||||||
@@ -25,6 +26,19 @@ 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;
|
||||||
postChat?: boolean;
|
postChat?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +48,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 };
|
||||||
}
|
}
|
||||||
@@ -61,30 +81,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 +162,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'));
|
||||||
|
|||||||
+7
-2
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+75
-1
@@ -133,7 +133,81 @@ export function bloodToZeal(blood: number): number {
|
|||||||
return Math.floor(Math.max(0, blood) / 5);
|
return Math.floor(Math.max(0, blood) / 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Runiczny Pancerz (Amulet Osłony): recharging costs 2 Mana per point, as an Akcja. */
|
/**
|
||||||
|
* 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;
|
export const RUNIC_ARMOR_MANA_PER_POINT = 2;
|
||||||
|
|
||||||
/** Points of Runiczny Pancerz that can actually be restored (never above max, never negative). */
|
/** Points of Runiczny Pancerz that can actually be restored (never above max, never negative). */
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* Runiczny Pancerz – restoring points with Mana (Amulet Osłony: Akcja, 2 Many za punkt).
|
* Runiczny Pancerz – a single hit of more than 5 damage disables 1 point (logic/damage.ts); disabled
|
||||||
* Degradation (every 5 absorbed damage, `runicCounter`) lives in logic/damage.ts.
|
* 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 type { CastableActor } from './spell-cast';
|
||||||
import { runicArmorManaCost, runicArmorRestorable } from './rules';
|
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 {
|
export interface RunicRestoreResult {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
restored: number;
|
restored: number;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
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, SPELL_DAMAGE_PROFILES } 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';
|
||||||
@@ -200,6 +200,11 @@ async function renderCastCard(
|
|||||||
ignoresArmor: !!spell.system.ignoresArmor,
|
ignoresArmor: !!spell.system.ignoresArmor,
|
||||||
// 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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function actorHasTalent(actor: CastableActor, talentId: string): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchDiscipline(disciplineId: string, targetId: string): boolean {
|
export 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 +52,8 @@ 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'],
|
||||||
|
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 +65,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
+2
-2
@@ -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.1",
|
"version": "1.5.2",
|
||||||
"manifest": "https://vtt-content.octoturge.com/packages/system/hbm-rpg-v3.json",
|
"manifest": "https://vtt-content.octoturge.com/packages/system/hbm-rpg-v3.json",
|
||||||
"download": "https://vtt-content.octoturge.com/packages/system/hbm-rpg-v3-v1.4.1.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"
|
||||||
|
|||||||
@@ -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}}">
|
||||||
⚔ {{t.name}}
|
⚔ {{t.name}}
|
||||||
</button>
|
</button>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user