/** * Magia Krwi - Blood Magic logic (Arcanum Sanguinis). * * 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 * * 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, greatSacrificeResult, krewFromLifeSteal, krewFromSelfHarm, krewFromWeaponHpLoss, sacrificeResult, startingKrew, type KrewTalent, } from './rules'; const SELF_HARM_THRESHOLD = 2; const FLAG_SCOPE = 'hbm-rpg-v3'; interface ActorWithItems extends CastableActor { items?: Iterable<{ type: string; system?: { slug?: string } }>; } 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?.[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; } export interface BloodSpendResult { ok: boolean; spent: number; remaining: number; reason?: string; } /** Deduct `amount` from `actor.system.attributes.blood.value`. */ export async function spendBlood(actor: CastableActor, amount: number): Promise { const blood = actor.system.attributes.blood; if (!blood) return { ok: false, spent: 0, remaining: 0, reason: 'no-blood-pool' }; if (amount <= 0) return { ok: true, spent: 0, remaining: blood.value }; if (blood.value < amount) { return { ok: false, spent: 0, remaining: blood.value, reason: 'insufficient-blood' }; } await actor.update({ 'system.attributes.blood.value': 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 { 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 }); /** * Samookaleczenie (Akcja, once per round): Test Magia (Walka Wręcz) T=2; lose HP = Sukcesy, * gain 1 Krew per HP. Większe Samookaleczenie (Krwisty Pojedynkowicz, Akcja Natychmiastowa): 2 Krew per HP. */ export async function selfHarm( actor: CastableActor, opts: { greater?: boolean; roller?: SuccessRoller } = {}, ): Promise { 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': (a.health?.value ?? 0) - hpLost, 'system.attributes.blood.value': a.blood.value + krewGained, ...bump('selfHarm', actor), }); 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; } /** * 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, bloodSpent: number, opts: { greater?: boolean; asZeal?: boolean } = {}, ): Promise<{ ok: boolean; hpGained: number; zealGained: number; reason?: string }> { const a = actor.system.attributes as any; 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; if (opts.greater && opts.asZeal) { zealGained = Math.min(bloodToZeal(bloodSpent), Math.max(0, (a.zeal?.max ?? 0) - (a.zeal?.value ?? 0))); update['system.attributes.zeal.value'] = (a.zeal?.value ?? 0) + zealGained; } else { 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 }; } /** * 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 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; }