feat: blood magic and Runiczny Pancerz per vault answers (1.5.0)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 17:12:21 +02:00
co-authored by Claude Sonnet 5.5
parent e4dc1842d3
commit 7b1f6794ad
16 changed files with 607 additions and 79 deletions
+126
View File
@@ -0,0 +1,126 @@
import { describe, it, expect, vi } from 'vitest';
import {
absorbBlood,
endOfFightKrew,
gainKrewFromWeaponDamage,
greatSacrifice,
resolveLifeSteal,
sacrifice,
selfHarm,
startOfFightKrew,
useLifeSteal,
} from '../../src/logic/blood-magic';
import { krewFromWeaponHpLoss, runicArmorPointsDisabled, startingKrew } from '../../src/logic/rules';
function makeActor(opts: { slugs?: string[]; blood?: number; hp?: number; zeal?: number; used?: Record<string, number> } = {}) {
const flags: Record<string, unknown> = {};
const actor: any = {
system: {
attributes: {
blood: { value: opts.blood ?? 0, max: 20 },
health: { value: opts.hp ?? 20, max: 30 },
zeal: { value: opts.zeal ?? 3, max: 5 },
magic: { value: 3, actual: 3 },
bloodUsed: { selfHarm: 0, lifeSteal: 0, sacrifice: 0, absorb: 0, ...(opts.used ?? {}) },
greatSacrificeUsed: 0,
},
},
items: (opts.slugs ?? []).map((slug) => ({ type: 'talent', system: { slug } })),
update: vi.fn(async () => undefined),
setFlag: vi.fn(async (_s: string, k: string, v: unknown) => { flags[k] = v; }),
getFlag: vi.fn((_s: string, k: string) => flags[k]),
unsetFlag: vi.fn(async (_s: string, k: string) => { delete flags[k]; }),
};
return actor;
}
describe('Samookaleczenie', () => {
it('loses HP = Sukcesy and gains 1 Krew per HP, once per round', async () => {
const actor = makeActor();
const res = await selfHarm(actor, { roller: async () => 3 });
expect(res).toMatchObject({ ok: true, hpLost: 3, krewGained: 3 });
expect(actor.update).toHaveBeenCalledWith(expect.objectContaining({
'system.attributes.health.value': 17,
'system.attributes.blood.value': 3,
'system.attributes.bloodUsed.selfHarm': 1,
}));
const again = makeActor({ used: { selfHarm: 1 } });
expect((await selfHarm(again, { roller: async () => 3 })).reason).toBe('already-used');
});
it('Większe Samookaleczenie needs the talent and gives 2 Krew per HP', async () => {
expect((await selfHarm(makeActor(), { greater: true, roller: async () => 2 })).reason).toBe('missing-talent');
const res = await selfHarm(makeActor({ slugs: ['krwisty-pojedynkowicz'] }), { greater: true, roller: async () => 2 });
expect(res.krewGained).toBe(4);
});
});
describe('Kradzież Życia', () => {
it('costs 1 Zapał, arms the next attack and pays Krew for HP actually lost', async () => {
const actor = makeActor({ zeal: 2 });
expect(await useLifeSteal(actor)).toEqual({ ok: true, stepsEasier: 1 });
expect(actor.update).toHaveBeenCalledWith({ 'system.attributes.zeal.value': 1 });
expect(await resolveLifeSteal(actor, 4)).toBe(4);
});
it('greater version pays 2 Krew per HP and may be used twice per round', async () => {
const actor = makeActor({ slugs: ['wieksza-kradziez-zycia'], used: { lifeSteal: 1 } });
expect((await useLifeSteal(actor, { greater: true })).stepsEasier).toBe(2);
expect(await resolveLifeSteal(actor, 3)).toBe(6);
const spent = makeActor({ slugs: ['wieksza-kradziez-zycia'], used: { lifeSteal: 2 } });
expect((await useLifeSteal(spent, { greater: true })).reason).toBe('already-used');
});
});
describe('Złożenie w Ofierze / Wielka Ofiara', () => {
it('heals Sukcesy + Magia and gains Krew = Sukcesy', async () => {
const actor = makeActor({ hp: 10 });
expect(await sacrifice(actor, 2)).toMatchObject({ ok: true, hpGained: 5, krewGained: 2 });
});
it('Wielka Ofiara kills when Sukcesy exceed the highest Atrybut, else heals', async () => {
const actor = makeActor({ slugs: ['rytualista-krwi'], hp: 10 });
const kill = await greatSacrifice(actor, 6, { highestAttribute: 5, health: 12 });
expect(kill).toMatchObject({ ok: true, kills: true, targetHpLost: 12, krewGained: 12 });
const other = makeActor({ slugs: ['rytualista-krwi'], hp: 10 });
expect(await greatSacrifice(other, 4, { highestAttribute: 5, health: 12 })).toMatchObject({ kills: false, hpGained: 4, krewGained: 0 });
expect((await sacrifice(actor, 2)).reason).toBe('replaced-by-wielka-ofiara');
});
});
describe('Wchłanianie Krwi', () => {
it('needs at least 1 Krew; 2 HP per Krew once per round, 3 HP greater', async () => {
expect((await absorbBlood(makeActor({ blood: 5 }), 0)).reason).toBe('minimum-one-blood');
expect((await absorbBlood(makeActor({ blood: 5, hp: 10 }), 3))).toMatchObject({ ok: true, hpGained: 6 });
expect((await absorbBlood(makeActor({ blood: 5, used: { absorb: 1 } }), 1)).reason).toBe('already-used');
expect((await absorbBlood(makeActor({ blood: 5, hp: 10 }), 2, { greater: true }))).toMatchObject({ hpGained: 6 });
});
});
describe('passive Krew', () => {
it('Szacunek do Życia 1 per 4 HP, Krwisty Pojedynkowicz 1 per 2 HP', async () => {
expect(krewFromWeaponHpLoss(9, 'respect-for-life')).toBe(2);
expect(krewFromWeaponHpLoss(9, 'krwisty-pojedynkowicz')).toBe(4);
expect(krewFromWeaponHpLoss(9, 'rytualista-krwi')).toBe(0);
expect(await gainKrewFromWeaponDamage(makeActor({ slugs: ['respect-for-life'] }), 9)).toBe(2);
expect(await gainKrewFromWeaponDamage(makeActor(), 9)).toBe(0);
});
it('fight start/end: Rytualista starts with 4, leftover Krew heals 2 HP each', async () => {
expect(startingKrew('rytualista-krwi')).toBe(4);
const rit = makeActor({ slugs: ['rytualista-krwi'], blood: 0 });
await startOfFightKrew(rit);
expect(rit.update).toHaveBeenCalledWith({ 'system.attributes.blood.value': 4 });
const end = makeActor({ slugs: ['respect-for-life'], blood: 5, hp: 10 });
expect(await endOfFightKrew(end)).toBe(10);
});
});
describe('Runiczny Pancerz – single hit', () => {
it('> 5 disables 1 point; shield-breakers disable per 2 damage', () => {
expect(runicArmorPointsDisabled(5)).toBe(0);
expect(runicArmorPointsDisabled(6)).toBe(1);
expect(runicArmorPointsDisabled(40)).toBe(1);
expect(runicArmorPointsDisabled(7, 2)).toBe(3);
});
});
+35
View File
@@ -80,6 +80,41 @@ describe('applyDamage', () => {
expect(r.hpDamage).toBe(6);
});
it('one hit above 5 (after Runiczny Pancerz) disables 1 point; hits never accumulate', async () => {
const big = makeActor({ magicalArmor: { value: 3, max: 3 } });
const r = await applyDamage(big.actor, { amount: 10, postChat: false });
expect(r.runicArmorDisabled).toBe(1);
expect(big.actor.update).toHaveBeenCalledWith(expect.objectContaining({ 'system.attributes.magicalArmor.value': 2 }));
const small = makeActor({ magicalArmor: { value: 3, max: 3 } });
await applyDamage(small.actor, { amount: 7, postChat: false });
await applyDamage(small.actor, { amount: 7, postChat: false });
expect(small.updates.every((u) => u['system.attributes.magicalArmor.value'] === 3)).toBe(true);
});
it('shield-breakers: every 2 damage disable a point and damage counts double against Magiczna Osłona', async () => {
const { actor } = makeActor({ magicalArmor: { value: 4, max: 4 }, magicalShield: { value: 6 } });
const r = await applyDamage(actor, { amount: 10, runicArmorStep: 2, shieldMultiplier: 2, postChat: false });
expect(r.runicArmorDisabled).toBe(4);
expect(r.absorbed.magicalShield).toBe(3);
expect(r.hpDamage).toBe(3);
});
it('Prawdziwa Dezintegracja destroys shield and disables all Runiczny Pancerz', async () => {
const { actor } = makeActor({ magicalArmor: { value: 3, max: 3 }, magicalShield: { value: 50 } });
await applyDamage(actor, { amount: 10, destroyMagicalDefenses: true, ignorePhysicalArmor: true, postChat: false });
expect(actor.update).toHaveBeenCalledWith(expect.objectContaining({
'system.attributes.magicalArmor.value': 0,
'system.attributes.magicalShield.value': 0,
}));
});
it('Rozerwanie Run permanently lowers the maximum', async () => {
const { actor } = makeActor({ magicalArmor: { value: 3, max: 3 } });
const r = await applyDamage(actor, { amount: 1, destroyRunicPoints: 1, postChat: false });
expect(r.runicArmorDestroyed).toBe(1);
expect(actor.update).toHaveBeenCalledWith(expect.objectContaining({ 'system.attributes.magicalArmor.max': 2 }));
});
it('marks shield as dropped when reduced to 0', async () => {
const { actor } = makeActor({ magicalShield: { value: 3 } });
const r = await applyDamage(actor, { amount: 5, postChat: false });
+3 -3
View File
@@ -17,7 +17,7 @@ function makeActor(overrides: Partial<any> = {}) {
zeal: { value: 0, max: 5 },
blood: { value: 0, max: 6 },
elixirTolerance: 2,
magicalArmor: { value: 4, runicCounter: 3 },
magicalArmor: { value: 4, max: 4, runicCounter: 3 },
...overrides.attributes,
},
skills: overrides.skills ?? {},
@@ -94,7 +94,7 @@ describe('rest', () => {
mana: { value: 1, max: 20 },
zeal: { value: 0, max: 5 },
blood: { value: 0, max: 6 },
magicalArmor: { value: 4, runicCounter: 3 },
magicalArmor: { value: 1, max: 4, runicCounter: 3 },
},
});
const r = await rest(actor, 'long');
@@ -103,7 +103,7 @@ describe('rest', () => {
expect(updates['system.attributes.mana.value']).toBe(20);
expect(updates['system.attributes.zeal.value']).toBe(5);
expect(updates['system.attributes.blood.value']).toBe(6);
expect(updates['system.attributes.magicalArmor.runicCounter']).toBe(0);
expect(updates['system.attributes.magicalArmor.value']).toBe(4);
expect(r.toleranceRecovered).toBe(1);
expect(updates['system.attributes.breathersTaken']).toBe(0);
expect(updates['system.attributes.manaBorrowed']).toBe(0);