From 7b1f6794adc9a43eb55c0f03f1503e9cf9084c09 Mon Sep 17 00:00:00 2001 From: Octoturge Date: Tue, 20 Oct 2026 17:12:21 +0200 Subject: [PATCH] feat: blood magic and Runiczny Pancerz per vault answers (1.5.0) Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 13 ++ package.json | 2 +- src/data/actor-character.ts | 11 ++ src/logic/blood-magic.ts | 270 ++++++++++++++++++++++++++------ src/logic/chat-cards.ts | 13 +- src/logic/combat.ts | 28 ++++ src/logic/damage.ts | 70 +++++++-- src/logic/rest.ts | 9 +- src/logic/rules.ts | 76 ++++++++- src/logic/runic-armor.ts | 14 +- src/logic/spell-cast.ts | 7 +- system.json | 4 +- templates/chat/spell-cast.hbs | 2 +- tests/logic/blood-magic.test.ts | 126 +++++++++++++++ tests/logic/damage.test.ts | 35 +++++ tests/logic/rest.test.ts | 6 +- 16 files changed, 607 insertions(+), 79 deletions(-) create mode 100644 tests/logic/blood-magic.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b5886f..6e0cc13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to the **HbM RPG v3** Foundry VTT system. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [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 diff --git a/package.json b/package.json index 6e7e20b..40aab09 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hbm-rpg-v3-foundry", - "version": "1.4.1", + "version": "1.5.0", "description": "Foundry VTT system for Homebrew Magic: Role Playing Game v3", "private": true, "type": "module", diff --git a/src/data/actor-character.ts b/src/data/actor-character.ts index 66711c2..fdf2c86 100644 --- a/src/data/actor-character.ts +++ b/src/data/actor-character.ts @@ -78,6 +78,15 @@ export class CharacterData extends foundry.abstract.TypeDataModel { breathersTaken: makeIntField(0, { min: 0 }), // Akcje Natychmiastowe used in the current round (1 free, Zapał buys more). immediateActionsUsed: makeIntField(0, { min: 0 }), + // 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 // mental-condition table when reaching thresholds. insanity: makeIntField(0, { min: 0 }), @@ -152,6 +161,8 @@ export class CharacterData extends foundry.abstract.TypeDataModel { manaBorrowed: number; breathersTaken: number; immediateActionsUsed: number; + bloodUsed: { selfHarm: number; lifeSteal: number; sacrifice: number; absorb: number }; + greatSacrificeUsed: number; insanity: number; magicalArmor: { value: number; max: number; runicCounter: number }; magicalShield: { value: number }; diff --git a/src/logic/blood-magic.ts b/src/logic/blood-magic.ts index 0ce8bd7..50c2520 100644 --- a/src/logic/blood-magic.ts +++ b/src/logic/blood-magic.ts @@ -1,35 +1,62 @@ /** - * Magia Krwi - Blood Magic logic (Arcanum Sanguinis Ch. III). + * Magia Krwi - Blood Magic logic (Arcanum Sanguinis). * - * Three primitives: - * - spendBlood(actor, n) : deduct n from blood pool, fail if insufficient. - * - selfHarm(actor, hp) : convert hp → blood at 2:1 ratio (2 HP → 1 Blood). - * - lifeStealOnDamage(...) : when actor inflicts damage with a blood spell, - * restore blood = floor(damage / 4) (capped at max). + * Krew sources and sinks follow the vault (Foundry Sync Brief §17): + * - selfHarm Samookaleczenie (Akcja) / Większe Samookaleczenie (Akcja Natychmiastowa) + * - useLifeSteal / resolveLifeSteal Kradzież Życia / Większa Kradzież Życia (1 Zapał) + * - sacrifice / greatSacrifice Złożenie w Ofierze / Wielka Ofiara (1 Zapał) + * - absorbBlood Wchłanianie Krwi / Większe Wchłanianie Krwi + * - gainKrewFromWeaponDamage passive gain (Szacunek do Życia, Krwisty Pojedynkowicz) + * - startOfFightKrew / endOfFightKrew Rytualista Krwi start pool, leftover Krew heals * - * Future hook: `Szacunek do Życia` talent triples self-harm cost (6 HP → 1 Blood). - * - * The cast pipeline (logic/spell-cast.ts) already deducts `bloodCost` directly; - * this module is the reusable API surface for UI dialogs and the damage hook. + * Per-round limits live in `attributes.bloodUsed` (reset every round by logic/combat.ts); + * Wielka Ofiara is once per day (`attributes.greatSacrificeUsed`, reset by Długi Odpoczynek). + * The cast pipeline (logic/spell-cast.ts) deducts `bloodCost` directly. */ +import { getMagicPowerEntry } from '../constants'; import type { CastableActor } from './spell-cast'; -import { bloodAbsorbHp, bloodToZeal } from './rules'; +import { + bloodAbsorbHp, + bloodToZeal, + greatSacrificeResult, + krewFromLifeSteal, + krewFromSelfHarm, + krewFromWeaponHpLoss, + sacrificeResult, + startingKrew, + type KrewTalent, +} from './rules'; -const SELF_HARM_RATIO = 2; // HP per 1 Blood -const RESPECT_FOR_LIFE_PENALTY = 3; // multiplier when talent present -const LIFE_STEAL_DIVISOR = 4; // damage / N → blood restored +const SELF_HARM_THRESHOLD = 2; +const FLAG_SCOPE = 'hbm-rpg-v3'; interface ActorWithItems extends CastableActor { items?: Iterable<{ type: string; system?: { slug?: string } }>; } -function hasRespectForLife(actor: ActorWithItems): boolean { - if (!actor.items) return false; - for (const it of actor.items) { +export type BloodAction = 'selfHarm' | 'lifeSteal' | 'sacrifice' | 'absorb'; + +/** Talent that decides passive Krew gain / starting pool, if any. */ +export function krewTalent(actor: CastableActor): KrewTalent | null { + const items = (actor as ActorWithItems).items; + if (!items) return null; + let found: KrewTalent | null = null; + for (const it of items) { if (it.type !== 'talent') continue; - const slug = (it as any).system?.slug ?? (it as any).flags?.['hbm-rpg-v3']?.slug; - if (slug === 'respect-for-life' || slug === 'szacunek-do-zycia') return true; + const slug = (it as any).system?.slug ?? (it as any).flags?.[FLAG_SCOPE]?.slug; + if (slug === 'rytualista-krwi') return 'rytualista-krwi'; + if (slug === 'krwisty-pojedynkowicz') found = 'krwisty-pojedynkowicz'; + else if ((slug === 'respect-for-life' || slug === 'szacunek-do-zycia') && !found) found = 'respect-for-life'; + } + return found; +} + +function hasTalent(actor: CastableActor, ...slugs: string[]): boolean { + const items = (actor as ActorWithItems).items; + if (!items) return false; + for (const it of items) { + if (it.type === 'talent' && slugs.includes((it as any).system?.slug ?? (it as any).flags?.[FLAG_SCOPE]?.slug)) return true; } return false; } @@ -53,33 +80,155 @@ export async function spendBlood(actor: CastableActor, amount: number): Promise< return { ok: true, spent: amount, remaining: blood.value - amount }; } +function used(actor: CastableActor, action: BloodAction): number { + return Number((actor.system.attributes as any).bloodUsed?.[action]) || 0; +} + +function bump(action: BloodAction, actor: CastableActor): Record { + 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 { + 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; + +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. - * Default ratio 2 HP → 1 Blood; with `Szacunek do Życia` talent → 6 HP → 1 Blood. + * 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, 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); - if (gained <= 0) return { ok: false, bloodGained: 0, reason: 'ratio-too-low' }; - const newBlood = Math.min(blood.max, blood.value + gained); +export async function selfHarm( + actor: CastableActor, + opts: { greater?: boolean; roller?: SuccessRoller } = {}, +): Promise { + const a = actor.system.attributes as any; + if (!a.blood) return fail('no-blood-pool'); + if (opts.greater && !hasTalent(actor, 'wieksze-samookaleczenie', 'krwisty-pojedynkowicz')) return fail('missing-talent'); + if (used(actor, 'selfHarm') >= 1) return fail('already-used'); + const successes = await (opts.roller ?? rollMagicMelee)(actor, SELF_HARM_THRESHOLD); + const hpLost = Math.min(successes, Math.max(0, a.health?.value ?? 0)); + const krewGained = addKrew(a.blood, krewFromSelfHarm(hpLost, !!opts.greater)); await actor.update({ - 'system.attributes.health.value': health.value - hpSpent, - 'system.attributes.blood.value': newBlood, + 'system.attributes.health.value': (a.health?.value ?? 0) - hpLost, + 'system.attributes.blood.value': a.blood.value + krewGained, + ...bump('selfHarm', actor), }); - return { ok: true, bloodGained: newBlood - blood.value }; + return { ok: true, successes, hpLost, krewGained }; +} + +async function spendZeal(actor: CastableActor, amount: number): Promise { + const zeal = (actor.system.attributes as any).zeal; + if (!zeal || zeal.value < amount) return false; + await actor.update({ 'system.attributes.zeal.value': zeal.value - amount }); + return true; } /** - * Wchłanianie Krwi: spend Krew to heal Żywotność – 2 HP per Krew (Akcja, once per round); - * Większe Wchłanianie Krwi: 3 HP per Krew (Akcja Natychmiastowa), or 1 Zapał per 5 Krew. + * Kradzież Życia: 1 Zapał, free Akcja Natychmiastowa (outside the normal limit), once per round + * (Większa Kradzież Życia: twice per round, two steps easier). Arms the next attack; call + * `resolveLifeSteal` with the Żywotność the target actually lost. + */ +export async function useLifeSteal(actor: CastableActor, opts: { greater?: boolean } = {}): Promise<{ ok: boolean; stepsEasier: number; reason?: string }> { + const limit = opts.greater ? 2 : 1; + if (!(actor.system.attributes as any).blood) return { ok: false, stepsEasier: 0, reason: 'no-blood-pool' }; + if (opts.greater && !hasTalent(actor, 'wieksza-kradziez-zycia')) return { ok: false, stepsEasier: 0, reason: 'missing-talent' }; + if (used(actor, 'lifeSteal') >= limit) return { ok: false, stepsEasier: 0, reason: 'already-used' }; + if (!(await spendZeal(actor, 1))) return { ok: false, stepsEasier: 0, reason: 'insufficient-zeal' }; + const stepsEasier = opts.greater ? 2 : 1; + await actor.update(bump('lifeSteal', actor)); + await (actor as any).setFlag?.(FLAG_SCOPE, 'lifeSteal', { stepsEasier, greater: !!opts.greater }); + return { ok: true, stepsEasier }; +} + +/** Kradzież Życia payout: 1 Krew (2 greater) per Żywotność the target actually lost. */ +export async function resolveLifeSteal(actor: CastableActor, hpActuallyLost: number): Promise { + 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 cutting / 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 { + 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 { + const a = actor.system.attributes as any; + if (!a.blood) return fail('no-blood-pool'); + if (!hasTalent(actor, 'wielka-ofiara', 'rytualista-krwi')) return fail('missing-talent'); + if (Number(a.greatSacrificeUsed) >= 1) return fail('already-used'); + if (!(await spendZeal(actor, 1))) return fail('insufficient-zeal'); + const res = greatSacrificeResult(successes, target.highestAttribute, target.health); + const hpGained = Math.min(res.heal, Math.max(0, (a.health?.max ?? 0) - (a.health?.value ?? 0))); + const krewGained = addKrew(a.blood, res.krew); + await actor.update({ + 'system.attributes.health.value': (a.health?.value ?? 0) + hpGained, + 'system.attributes.blood.value': a.blood.value + krewGained, + 'system.attributes.greatSacrificeUsed': 1, + }); + return { ok: true, successes, hpGained, krewGained, kills: res.kills, targetHpLost: res.kills ? target.health : 0 }; +} + +/** + * Wchłanianie Krwi (Akcja, once per round, min. 1 Krew): 2 HP per Krew. + * Większe Wchłanianie Krwi (Akcja Natychmiastowa): 3 HP per Krew, or 5 Krew → 1 Zapał. */ export async function absorbBlood( actor: CastableActor, @@ -87,8 +236,9 @@ export async function absorbBlood( opts: { greater?: boolean; asZeal?: boolean } = {}, ): Promise<{ ok: boolean; hpGained: number; zealGained: number; reason?: string }> { const a = actor.system.attributes as any; - if (bloodSpent <= 0) return { ok: true, hpGained: 0, zealGained: 0 }; + if (bloodSpent < 1) return { ok: false, hpGained: 0, zealGained: 0, reason: 'minimum-one-blood' }; if (!a.blood || a.blood.value < bloodSpent) return { ok: false, hpGained: 0, zealGained: 0, reason: 'insufficient-blood' }; + if (!opts.greater && used(actor, 'absorb') >= 1) return { ok: false, hpGained: 0, zealGained: 0, reason: 'already-used' }; const update: Record = { 'system.attributes.blood.value': a.blood.value - bloodSpent }; let hpGained = 0; let zealGained = 0; @@ -99,18 +249,40 @@ export async function absorbBlood( hpGained = Math.min(bloodAbsorbHp(bloodSpent, !!opts.greater), Math.max(0, (a.health?.max ?? 0) - (a.health?.value ?? 0))); update['system.attributes.health.value'] = (a.health?.value ?? 0) + hpGained; } + if (!opts.greater) Object.assign(update, bump('absorb', actor)); await actor.update(update); return { ok: true, hpGained, zealGained }; } -/** Life Steal: invoked from the damage-application hook when the source is a blood spell. */ -export async function lifeStealOnDamage(actor: CastableActor, damageDealt: number): Promise { - if (damageDealt <= 0) return 0; - const blood = actor.system.attributes.blood; +/** + * Passive Krew (talents): 1 per 4 HP lost (Szacunek do Życia) / 1 per 2 HP (Krwisty Pojedynkowicz), + * by either side, only from cutting or piercing weapons. Call for the victim and the attacker. + */ +export async function gainKrewFromWeaponDamage(actor: CastableActor, hpLost: number): Promise { + const blood = (actor.system.attributes as any)?.blood; if (!blood) return 0; - const restore = Math.floor(damageDealt / LIFE_STEAL_DIVISOR); - if (restore <= 0) return 0; - const newBlood = Math.min(blood.max, blood.value + restore); - await actor.update({ 'system.attributes.blood.value': newBlood }); - return newBlood - blood.value; + const gained = addKrew(blood, krewFromWeaponHpLoss(hpLost, krewTalent(actor))); + if (gained > 0) await actor.update({ 'system.attributes.blood.value': blood.value + gained }); + return gained; +} + +/** Start of a fight: Krew pool resets to 0 (Rytualista Krwi: 4) for blood-talent holders. */ +export async function startOfFightKrew(actor: CastableActor): Promise { + 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 { + 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; } diff --git a/src/logic/chat-cards.ts b/src/logic/chat-cards.ts index 4ec7220..8eaa90d 100644 --- a/src/logic/chat-cards.ts +++ b/src/logic/chat-cards.ts @@ -9,6 +9,7 @@ */ import { applyDamage } from './damage'; +import type { SpellDamageProfile } from './rules'; import { CONDITIONS } from './conditions'; declare const Hooks: any; @@ -71,12 +72,18 @@ async function onApplyDamage(btn: HTMLElement): Promise { return; } const ignoresArmor = btn.dataset.ignoresArmor === 'true'; + let profile: SpellDamageProfile | null = null; + try { profile = btn.dataset.profile ? JSON.parse(btn.dataset.profile) : null; } catch { /* ignore */ } await applyDamage(target, { amount, type: (btn.dataset.type as any) ?? 'magical', - ignoreMagicalArmor: ignoresArmor, - ignoreMagicalShield: ignoresArmor, - ignorePhysicalArmor: ignoresArmor, + // A spell-specific profile replaces the blanket "ignores all armour" flag. + ignoreMagicalArmor: profile ? !!profile.ignoreMagicalArmor : ignoresArmor, + ignoreMagicalShield: profile ? !!profile.ignoreMagicalShield : ignoresArmor, + ignorePhysicalArmor: profile ? !!profile.ignorePhysicalArmor : ignoresArmor, + runicArmorStep: profile?.runicArmorStep, + shieldMultiplier: profile?.shieldMultiplier, + destroyMagicalDefenses: profile?.destroyMagicalDefenses, minDamageAfterArmor: btn.dataset.minAfterArmor === 'true', } as any); } diff --git a/src/logic/combat.ts b/src/logic/combat.ts index f4b5c5c..d5a0bc4 100644 --- a/src/logic/combat.ts +++ b/src/logic/combat.ts @@ -7,6 +7,8 @@ */ import { applyDamage } from './damage'; +import { startOfFightKrew, endOfFightKrew } from './blood-magic'; +import { restoreAllRunicArmor } from './runic-armor'; function hasStatus(actor: any, id: string): boolean { const effects = actor?.effects ?? []; @@ -55,6 +57,15 @@ export function registerCombatHooks(): void { if (actor?.system?.attributes?.immediateActionsUsed) { await actor.update({ 'system.attributes.immediateActionsUsed': 0 }); } + const bu = actor?.system?.attributes?.bloodUsed; + if (bu && Object.values(bu).some((n) => Number(n) > 0)) { + await actor.update({ + 'system.attributes.bloodUsed.selfHarm': 0, + 'system.attributes.bloodUsed.lifeSteal': 0, + 'system.attributes.bloodUsed.sacrifice': 0, + 'system.attributes.bloodUsed.absorb': 0, + }); + } } ChatMessage.create({ content: `${game.i18n.localize('HBM.combat.newRound')}`, @@ -62,6 +73,23 @@ export function registerCombatHooks(): void { }); }); + // Start of a fight: Krew pool resets (Rytualista Krwi starts with 4). + Hooks.on('combatStart', async (combat: Combat) => { + for (const c of (combat as any)?.combatants ?? []) { + if (c.actor?.type === 'character') await startOfFightKrew(c.actor); + } + }); + + // End of a fight: disabled Runiczny Pancerz returns, per-round counters clear, leftover Krew heals. + Hooks.on('deleteCombat', async (combat: Combat) => { + for (const c of (combat as any)?.combatants ?? []) { + const actor = c.actor; + if (!actor || actor.type !== 'character') continue; + await restoreAllRunicArmor(actor); + await endOfFightKrew(actor); + } + }); + Hooks.on('combatTurn', async (combat: Combat) => { // The previous combatant's turn just ended: expire their timed Stany. const prevId = (combat as any).previous?.combatantId; diff --git a/src/logic/damage.ts b/src/logic/damage.ts index 72beb46..83efb8a 100644 --- a/src/logic/damage.ts +++ b/src/logic/damage.ts @@ -1,6 +1,6 @@ /** * Damage application engine - flows through 4 layers: - * 1. Magical Armor (DR + every 5 dmg absorbed degrades value by 1 via runicCounter) + * 1. Runiczny Pancerz (DR; one hit > 5 disables 1 point until end of fight / restore) * 2. Magical Shield (raw temp HP; cannot be healed; vanishes at 0) * 3. Physical Armor (flat DR; optionally degrade `condition` by 1) * 4. Health (the leftover hits HP) @@ -14,7 +14,8 @@ */ import { DamageType } from '../constants'; -import { triggersStun, concentrationTest } from './rules'; +import { triggersStun, concentrationTest, runicArmorPointsDisabled } from './rules'; +import { gainKrewFromWeaponDamage } from './blood-magic'; export interface ApplyDamageOptions { amount: number; @@ -25,6 +26,18 @@ export interface ApplyDamageOptions { damageArmor?: boolean; /** Magiczny Pocisk: at least 1 damage gets through Pancerz to Żywotność. */ minDamageAfterArmor?: boolean; + /** Shield-breaker: every N (2 instead of 5) damage disable a Runiczny Pancerz point. */ + runicArmorStep?: number; + /** Shield-breaker: damage counts ×N against Magiczna Osłona. */ + shieldMultiplier?: number; + /** Prawdziwa Dezintegracja: disables all Runiczny Pancerz points and destroys Magiczna Osłona. */ + destroyMagicalDefenses?: boolean; + /** Rozerwanie Run: permanently destroys N Runiczny Pancerz points (lowers its maximum). */ + destroyRunicPoints?: number; + /** Wound kind of a weapon hit (gear data has none yet – callers pass it); drives passive Krew. */ + woundKind?: 'cutting' | 'piercing' | 'blunt'; + /** Attacker, for passive Krew gain (Szacunek do Życia / Krwisty Pojedynkowicz). */ + sourceActor?: any; postChat?: boolean; } @@ -34,6 +47,12 @@ export interface DamageReport { hpDamage: number; shieldDropped: boolean; oszolomionyApplied: boolean; + /** Runiczny Pancerz points disabled by this hit (restored at end of fight). */ + runicArmorDisabled?: number; + /** Runiczny Pancerz points destroyed permanently by this hit. */ + runicArmorDestroyed?: number; + /** Krew gained by the victim / attacker through weapon-damage talents. */ + krewGained?: { victim: number; source: number }; /** Dusza (Determinacja) 4:S for a concentrating target; absent when no Żywotność was lost. */ concentrationTest?: { threshold: number; successes: number }; } @@ -61,30 +80,45 @@ export async function applyDamage(actor: ActorLike, opts: ApplyDamageOptions): P let remaining = total; - // Layer 1: Magical Armor - DR-based; runicCounter accumulates incoming damage. + // Layer 1: Runiczny Pancerz - flat DR. ONE hit disables points (never accumulated across hits): + // > 5 damage after the step → 1 point; shield-breakers: every 2 damage → 1 point. Disabled + // points come back at the end of the fight / when restored (see logic/runic-armor.ts). + if (a.magicalArmor && opts.destroyRunicPoints) { + // Rozerwanie Run: permanent – lowers the maximum, cannot be restored. + const value = Math.max(0, Number(a.magicalArmor.value) || 0); + const max = Math.max(0, Number(a.magicalArmor.max) || 0); + const lost = Math.min(max, opts.destroyRunicPoints); + updates['system.attributes.magicalArmor.max'] = max - lost; + updates['system.attributes.magicalArmor.value'] = Math.min(value, max - lost); + report.runicArmorDestroyed = lost; + } if (!opts.ignoreMagicalArmor && a.magicalArmor) { const dr = Math.max(0, Number(a.magicalArmor.value) || 0); const absorbed = Math.min(dr, remaining); if (absorbed > 0) { - const incomingDmg = remaining; + const incoming = remaining; report.absorbed.magicalArmor = absorbed; remaining -= absorbed; - const newCounter = (Number(a.magicalArmor.runicCounter) || 0) + incomingDmg; - const decrements = Math.floor(newCounter / 5); - const newValue = Math.max(0, dr - decrements); - updates['system.attributes.magicalArmor.runicCounter'] = newCounter % 5; + const disabled = opts.destroyMagicalDefenses + ? dr + : runicArmorPointsDisabled(opts.runicArmorStep ? incoming : remaining, opts.runicArmorStep); + const current = (updates['system.attributes.magicalArmor.value'] as number | undefined) ?? dr; + const newValue = Math.max(0, Math.min(current, dr) - disabled); + if (disabled > 0) report.runicArmorDisabled = Math.min(dr, disabled); updates['system.attributes.magicalArmor.value'] = newValue; } } - // Layer 2: Magical Shield - pure temp HP (raw subtraction). + // Layer 2: Magiczna Osłona - pure temp HP; shield-breakers count damage ×N against it. if (remaining > 0 && !opts.ignoreMagicalShield && a.magicalShield) { const shield = Math.max(0, Number(a.magicalShield.value) || 0); - const taken = Math.min(shield, remaining); - if (taken > 0) { - report.absorbed.magicalShield = taken; - remaining -= taken; - const newShield = shield - taken; + const mult = Math.max(1, opts.shieldMultiplier ?? 1); + const effective = Math.min(shield, remaining * mult); + if (effective > 0) { + const consumed = Math.ceil(effective / mult); + report.absorbed.magicalShield = consumed; + remaining -= consumed; + const newShield = opts.destroyMagicalDefenses ? 0 : shield - effective; updates['system.attributes.magicalShield.value'] = newShield; if (newShield === 0) report.shieldDropped = true; } @@ -127,6 +161,14 @@ export async function applyDamage(actor: ActorLike, opts: ApplyDamageOptions): P if (Object.keys(updates).length > 0) await actor.update(updates); + // Passive Krew from cutting / piercing weapons (talents): victim and attacker side. + if (report.hpDamage > 0 && (opts.woundKind === 'cutting' || opts.woundKind === 'piercing')) { + report.krewGained = { + victim: await gainKrewFromWeaponDamage(actor as any, report.hpDamage), + source: opts.sourceActor ? await gainKrewFromWeaponDamage(opts.sourceActor, report.hpDamage) : 0, + }; + } + // Oszołomiony: a single hit that costs ≥ half max Żywotność after all reductions. if (triggersStun(report.hpDamage, Number(a.health?.max) || 0) && actor.toggleStatusEffect) { const already = actor.effects?.find((e: any) => e.statuses?.has?.('oszolomiony')); diff --git a/src/logic/rest.ts b/src/logic/rest.ts index 906118f..c46b00e 100644 --- a/src/logic/rest.ts +++ b/src/logic/rest.ts @@ -141,8 +141,13 @@ export async function rest(actor: CastableActor, kind: RestKind): Promise 0) { - update['system.attributes.magicalArmor.runicCounter'] = 0; + // Disabled Runiczny Pancerz points come back (only Rozerwanie Run is permanent). + if (a.magicalArmor && a.magicalArmor.value < a.magicalArmor.max) { + update['system.attributes.magicalArmor.value'] = a.magicalArmor.max; + } + if (a.greatSacrificeUsed) update['system.attributes.greatSacrificeUsed'] = 0; + if (a.bloodUsed) { + for (const k of ['selfHarm', 'lifeSteal', 'sacrifice', 'absorb']) update[`system.attributes.bloodUsed.${k}`] = 0; } } diff --git a/src/logic/rules.ts b/src/logic/rules.ts index 4258a61..c5ffcdb 100644 --- a/src/logic/rules.ts +++ b/src/logic/rules.ts @@ -133,7 +133,81 @@ export function bloodToZeal(blood: number): number { 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> = { + '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 (cutting/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). */ diff --git a/src/logic/runic-armor.ts b/src/logic/runic-armor.ts index a144da7..241d028 100644 --- a/src/logic/runic-armor.ts +++ b/src/logic/runic-armor.ts @@ -1,11 +1,21 @@ /** - * Runiczny Pancerz – restoring points with Mana (Amulet Osłony: Akcja, 2 Many za punkt). - * Degradation (every 5 absorbed damage, `runicCounter`) lives in logic/damage.ts. + * 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 { + 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; diff --git a/src/logic/spell-cast.ts b/src/logic/spell-cast.ts index 60c4e40..3c38ec3 100644 --- a/src/logic/spell-cast.ts +++ b/src/logic/spell-cast.ts @@ -16,7 +16,7 @@ import { HbmTSRoll } from '../dice/ts-roll'; import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD, getMagicPowerEntry } from '../constants'; -import { manaToBorrow, exhaustionDice, manaExhaustionTier, overLimitTest } from './rules'; +import { manaToBorrow, exhaustionDice, manaExhaustionTier, overLimitTest, SPELL_DAMAGE_PROFILES } from './rules'; import { validateCast } from './spell-validation'; import type { ValidationResult } from './spell-validation'; import { rollSpellDamage } from './spell-damage'; @@ -200,6 +200,11 @@ async function renderCastCard( ignoresArmor: !!spell.system.ignoresArmor, // Magiczny Pocisk: at least 1 damage reaches Żywotność after Pancerz. minAfterArmor: ((spell as any).flags?.['hbm-rpg-v3']?.slug ?? (spell.system as any).slug) === 'magic-missile', + // Shield-breakers / Szkarłatny Pocisk / Prawdziwa Dezintegracja treat the magical defences specially. + profile: (() => { + const p = SPELL_DAMAGE_PROFILES[((spell as any).flags?.['hbm-rpg-v3']?.slug ?? (spell.system as any).slug) as string]; + return p ? JSON.stringify(p) : ''; + })(), targets: targets.length > 0 ? targets : null, }; } diff --git a/system.json b/system.json index c5eb8e6..c34ccd8 100644 --- a/system.json +++ b/system.json @@ -2,9 +2,9 @@ "id": "hbm-rpg-v3", "title": "Homebrew Magic: RPG v3", "description": "System Foundry VTT dla Homebrew Magic: Role Playing Game v3 - autorska gra fabularna z mechaniką puli kości d6 (TS - Trudność:Sukcesy).", - "version": "1.4.1", + "version": "1.5.0", "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": { "minimum": "13", "verified": "14" diff --git a/templates/chat/spell-cast.hbs b/templates/chat/spell-cast.hbs index f00a013..b4c8c54 100644 --- a/templates/chat/spell-cast.hbs +++ b/templates/chat/spell-cast.hbs @@ -51,7 +51,7 @@ {{#if damage.targets}}
{{#each damage.targets as |t|}} - {{/each}} diff --git a/tests/logic/blood-magic.test.ts b/tests/logic/blood-magic.test.ts new file mode 100644 index 0000000..8eb0a4b --- /dev/null +++ b/tests/logic/blood-magic.test.ts @@ -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 } = {}) { + const flags: Record = {}; + 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); + }); +}); diff --git a/tests/logic/damage.test.ts b/tests/logic/damage.test.ts index 5c5c416..c9e422c 100644 --- a/tests/logic/damage.test.ts +++ b/tests/logic/damage.test.ts @@ -80,6 +80,41 @@ describe('applyDamage', () => { expect(r.hpDamage).toBe(6); }); + it('one hit above 5 (after Runiczny Pancerz) disables 1 point; hits never accumulate', async () => { + const big = makeActor({ magicalArmor: { value: 3, max: 3 } }); + const r = await applyDamage(big.actor, { amount: 10, postChat: false }); + expect(r.runicArmorDisabled).toBe(1); + expect(big.actor.update).toHaveBeenCalledWith(expect.objectContaining({ 'system.attributes.magicalArmor.value': 2 })); + const small = makeActor({ magicalArmor: { value: 3, max: 3 } }); + await applyDamage(small.actor, { amount: 7, postChat: false }); + await applyDamage(small.actor, { amount: 7, postChat: false }); + expect(small.updates.every((u) => u['system.attributes.magicalArmor.value'] === 3)).toBe(true); + }); + + it('shield-breakers: every 2 damage disable a point and damage counts double against Magiczna Osłona', async () => { + const { actor } = makeActor({ magicalArmor: { value: 4, max: 4 }, magicalShield: { value: 6 } }); + const r = await applyDamage(actor, { amount: 10, runicArmorStep: 2, shieldMultiplier: 2, postChat: false }); + expect(r.runicArmorDisabled).toBe(4); + expect(r.absorbed.magicalShield).toBe(3); + expect(r.hpDamage).toBe(3); + }); + + it('Prawdziwa Dezintegracja destroys shield and disables all Runiczny Pancerz', async () => { + const { actor } = makeActor({ magicalArmor: { value: 3, max: 3 }, magicalShield: { value: 50 } }); + await applyDamage(actor, { amount: 10, destroyMagicalDefenses: true, ignorePhysicalArmor: true, postChat: false }); + expect(actor.update).toHaveBeenCalledWith(expect.objectContaining({ + 'system.attributes.magicalArmor.value': 0, + 'system.attributes.magicalShield.value': 0, + })); + }); + + it('Rozerwanie Run permanently lowers the maximum', async () => { + const { actor } = makeActor({ magicalArmor: { value: 3, max: 3 } }); + const r = await applyDamage(actor, { amount: 1, destroyRunicPoints: 1, postChat: false }); + expect(r.runicArmorDestroyed).toBe(1); + expect(actor.update).toHaveBeenCalledWith(expect.objectContaining({ 'system.attributes.magicalArmor.max': 2 })); + }); + it('marks shield as dropped when reduced to 0', async () => { const { actor } = makeActor({ magicalShield: { value: 3 } }); const r = await applyDamage(actor, { amount: 5, postChat: false }); diff --git a/tests/logic/rest.test.ts b/tests/logic/rest.test.ts index 2cd2260..e1391a9 100644 --- a/tests/logic/rest.test.ts +++ b/tests/logic/rest.test.ts @@ -17,7 +17,7 @@ function makeActor(overrides: Partial = {}) { zeal: { value: 0, max: 5 }, blood: { value: 0, max: 6 }, elixirTolerance: 2, - magicalArmor: { value: 4, runicCounter: 3 }, + magicalArmor: { value: 4, max: 4, runicCounter: 3 }, ...overrides.attributes, }, skills: overrides.skills ?? {}, @@ -94,7 +94,7 @@ describe('rest', () => { mana: { value: 1, max: 20 }, zeal: { value: 0, max: 5 }, blood: { value: 0, max: 6 }, - magicalArmor: { value: 4, runicCounter: 3 }, + magicalArmor: { value: 1, max: 4, runicCounter: 3 }, }, }); const r = await rest(actor, 'long'); @@ -103,7 +103,7 @@ describe('rest', () => { expect(updates['system.attributes.mana.value']).toBe(20); expect(updates['system.attributes.zeal.value']).toBe(5); expect(updates['system.attributes.blood.value']).toBe(6); - expect(updates['system.attributes.magicalArmor.runicCounter']).toBe(0); + expect(updates['system.attributes.magicalArmor.value']).toBe(4); expect(r.toleranceRecovered).toBe(1); expect(updates['system.attributes.breathersTaken']).toBe(0); expect(updates['system.attributes.manaBorrowed']).toBe(0); -- 2.54.0