Initial commit
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getZealRegen } from '../../src/logic/combat';
|
||||
|
||||
describe('getZealRegen', () => {
|
||||
it('returns base 1 when no flag set', () => {
|
||||
const actor = { getFlag: () => undefined };
|
||||
expect(getZealRegen(actor)).toBe(1);
|
||||
});
|
||||
|
||||
it('adds bonus from flag', () => {
|
||||
const actor = { getFlag: (_ns: string, key: string) => (key === 'zealRegenBonus' ? 2 : undefined) };
|
||||
expect(getZealRegen(actor)).toBe(3);
|
||||
});
|
||||
|
||||
it('floors at 0', () => {
|
||||
const actor = { getFlag: () => -5 };
|
||||
expect(getZealRegen(actor)).toBe(0);
|
||||
});
|
||||
|
||||
it('handles missing getFlag gracefully', () => {
|
||||
expect(getZealRegen({})).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { applyDamage } from '../../src/logic/damage';
|
||||
|
||||
function makeActor(overrides: Partial<any> = {}) {
|
||||
const updates: Record<string, unknown>[] = [];
|
||||
const actor: any = {
|
||||
name: 'Test',
|
||||
type: 'character',
|
||||
system: {
|
||||
attributes: {
|
||||
body: { value: 3 },
|
||||
mind: { value: 3 },
|
||||
soul: { value: 3 },
|
||||
magicalArmor: { value: 0, runicCounter: 0 },
|
||||
magicalShield: { value: 0 },
|
||||
physicalArmor: { value: 0 },
|
||||
health: { value: 30, max: 30 },
|
||||
...overrides,
|
||||
},
|
||||
},
|
||||
items: [],
|
||||
update: vi.fn(async (u: Record<string, unknown>) => { updates.push(u); }),
|
||||
toggleStatusEffect: vi.fn(async () => undefined),
|
||||
};
|
||||
return { actor, updates };
|
||||
}
|
||||
|
||||
describe('applyDamage', () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as any).ChatMessage = { create: vi.fn(async () => undefined) };
|
||||
});
|
||||
|
||||
it('applies plain HP damage when no armor', async () => {
|
||||
const { actor } = makeActor();
|
||||
const r = await applyDamage(actor, { amount: 7, postChat: false });
|
||||
expect(r.hpDamage).toBe(7);
|
||||
expect(actor.system.attributes.health.value).toBe(30); // pre-update; check call
|
||||
expect(actor.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
'system.attributes.health.value': 23,
|
||||
}));
|
||||
});
|
||||
|
||||
it('absorbs through magical armor + shield + physical armor', async () => {
|
||||
const { actor } = makeActor({
|
||||
magicalArmor: { value: 3, runicCounter: 0 },
|
||||
magicalShield: { value: 4 },
|
||||
physicalArmor: { value: 2 },
|
||||
});
|
||||
const r = await applyDamage(actor, { amount: 12, postChat: false });
|
||||
expect(r.absorbed.magicalArmor).toBe(3);
|
||||
expect(r.absorbed.magicalShield).toBe(4);
|
||||
expect(r.absorbed.physicalArmor).toBe(2);
|
||||
expect(r.hpDamage).toBe(3);
|
||||
});
|
||||
|
||||
it('ignoreMagicalArmor skips layer 1', async () => {
|
||||
const { actor } = makeActor({
|
||||
magicalArmor: { value: 5, runicCounter: 0 },
|
||||
});
|
||||
const r = await applyDamage(actor, { amount: 4, ignoreMagicalArmor: true, postChat: false });
|
||||
expect(r.absorbed.magicalArmor).toBe(0);
|
||||
expect(r.hpDamage).toBe(4);
|
||||
});
|
||||
|
||||
it('ignoreMagicalShield skips layer 2', async () => {
|
||||
const { actor } = makeActor({
|
||||
magicalShield: { value: 10 },
|
||||
});
|
||||
const r = await applyDamage(actor, { amount: 5, ignoreMagicalShield: true, postChat: false });
|
||||
expect(r.absorbed.magicalShield).toBe(0);
|
||||
expect(r.hpDamage).toBe(5);
|
||||
});
|
||||
|
||||
it('ignorePhysicalArmor skips layer 3', async () => {
|
||||
const { actor } = makeActor({
|
||||
physicalArmor: { value: 4 },
|
||||
});
|
||||
const r = await applyDamage(actor, { amount: 6, ignorePhysicalArmor: true, postChat: false });
|
||||
expect(r.absorbed.physicalArmor).toBe(0);
|
||||
expect(r.hpDamage).toBe(6);
|
||||
});
|
||||
|
||||
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 });
|
||||
expect(r.shieldDropped).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import '../setup';
|
||||
import { rest } from '../../src/logic/rest';
|
||||
|
||||
function makeActor(overrides: Partial<any> = {}) {
|
||||
const updates: Record<string, unknown> = {};
|
||||
const deletedEffects: string[] = [];
|
||||
const actor: any = {
|
||||
type: 'character',
|
||||
items: [],
|
||||
effects: overrides.effects ?? [],
|
||||
system: {
|
||||
attributes: {
|
||||
body: { value: 3 },
|
||||
health: { value: 10, max: 30 },
|
||||
mana: { value: 1, max: 20 },
|
||||
zeal: { value: 0, max: 5 },
|
||||
blood: { value: 0, max: 6 },
|
||||
elixirTolerance: 2,
|
||||
magicalArmor: { value: 4, runicCounter: 3 },
|
||||
...overrides.attributes,
|
||||
},
|
||||
skills: overrides.skills ?? {},
|
||||
},
|
||||
update: vi.fn(async (u: Record<string, unknown>) => { Object.assign(updates, u); }),
|
||||
deleteEmbeddedDocuments: vi.fn(async (_t: string, ids: string[]) => { deletedEffects.push(...ids); }),
|
||||
unsetFlag: vi.fn(async () => undefined),
|
||||
};
|
||||
return { actor, updates, deletedEffects };
|
||||
}
|
||||
|
||||
describe('rest', () => {
|
||||
it('take a breather restores all mana and 1d6 + endurance HP, leaves zeal/blood alone', async () => {
|
||||
const { actor, updates } = makeActor({
|
||||
attributes: {
|
||||
health: { value: 10, max: 30 },
|
||||
mana: { value: 1, max: 20 },
|
||||
},
|
||||
skills: {
|
||||
endurance: { value: 2 },
|
||||
},
|
||||
});
|
||||
const r = await rest(actor, 'breather');
|
||||
expect(r.kind).toBe('breather');
|
||||
expect(r.hpRestored).toBe(6); // 4 (stub roll default) + 2 (endurance) = 6
|
||||
expect(r.manaRestored).toBe(19);
|
||||
expect(updates['system.attributes.health.value']).toBe(16);
|
||||
expect(updates['system.attributes.mana.value']).toBe(20);
|
||||
expect(updates['system.attributes.zeal.value']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('short rest restores all mana, 1/3 HP, and removes unconscious state', async () => {
|
||||
const effects = [
|
||||
{ id: 'nieprzytomny', statuses: new Set(['nieprzytomny']) },
|
||||
{ id: 'przewrocony', statuses: new Set(['przewrocony']) },
|
||||
];
|
||||
const { actor, updates, deletedEffects } = makeActor({
|
||||
effects,
|
||||
attributes: {
|
||||
health: { value: 10, max: 30 },
|
||||
mana: { value: 1, max: 20 },
|
||||
},
|
||||
});
|
||||
const r = await rest(actor, 'short');
|
||||
expect(r.kind).toBe('short');
|
||||
expect(r.hpRestored).toBe(10); // 30 / 3 = 10
|
||||
expect(r.manaRestored).toBe(19);
|
||||
expect(updates['system.attributes.health.value']).toBe(20);
|
||||
expect(updates['system.attributes.mana.value']).toBe(20);
|
||||
expect(deletedEffects).toContain('nieprzytomny');
|
||||
expect(deletedEffects).not.toContain('przewrocony');
|
||||
expect(r.effectsCleared).toBe(1);
|
||||
});
|
||||
|
||||
it('long rest fully restores HP/mana/zeal/blood, resets runic counter, and clears all conditions and temporary effects', async () => {
|
||||
const effects = [
|
||||
{ id: 'nieprzytomny', statuses: new Set(['nieprzytomny']) },
|
||||
{ id: 'przewrocony', statuses: new Set(['przewrocony']) },
|
||||
{ id: 'eff-1', origin: null, duration: { rounds: 3 } },
|
||||
{ id: 'eff-2', origin: 'Item.abc', duration: { rounds: 3 } },
|
||||
];
|
||||
const { actor, updates, deletedEffects } = makeActor({
|
||||
effects,
|
||||
attributes: {
|
||||
health: { value: 10, max: 30 },
|
||||
mana: { value: 1, max: 20 },
|
||||
zeal: { value: 0, max: 5 },
|
||||
blood: { value: 0, max: 6 },
|
||||
magicalArmor: { value: 4, runicCounter: 3 },
|
||||
},
|
||||
});
|
||||
const r = await rest(actor, 'long');
|
||||
expect(r.kind).toBe('long');
|
||||
expect(updates['system.attributes.health.value']).toBe(30);
|
||||
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(r.toleranceRecovered).toBe(1);
|
||||
|
||||
expect(deletedEffects).toContain('nieprzytomny');
|
||||
expect(deletedEffects).toContain('przewrocony');
|
||||
expect(deletedEffects).toContain('eff-1');
|
||||
expect(deletedEffects).not.toContain('eff-2');
|
||||
expect(r.effectsCleared).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { migration_1_2_0 } from '../../src/migrations/1.2.0';
|
||||
|
||||
describe('migration 1.2.0', () => {
|
||||
it('sets zealRegenBonus flag on character actors that lack it', async () => {
|
||||
const setFlag = vi.fn(async () => undefined);
|
||||
const character = {
|
||||
type: 'character',
|
||||
items: [],
|
||||
effects: [],
|
||||
getFlag: () => undefined,
|
||||
setFlag,
|
||||
update: vi.fn(async () => undefined),
|
||||
};
|
||||
(globalThis as any).game = {
|
||||
...(globalThis as any).game,
|
||||
user: { isGM: true },
|
||||
actors: { contents: [character] },
|
||||
items: { contents: [] },
|
||||
};
|
||||
await migration_1_2_0.run();
|
||||
expect(setFlag).toHaveBeenCalledWith('hbm-rpg-v3', 'zealRegenBonus', 0);
|
||||
});
|
||||
|
||||
it('attaches a zeal-regen ActiveEffect to a +1 Zapał talent', async () => {
|
||||
const createEmbeddedDocuments = vi.fn(async () => undefined);
|
||||
const talent = {
|
||||
type: 'talent',
|
||||
system: { description: 'Na początku tury otrzymujesz +1 Zapał.' },
|
||||
effects: [],
|
||||
createEmbeddedDocuments,
|
||||
};
|
||||
(globalThis as any).game = {
|
||||
...(globalThis as any).game,
|
||||
user: { isGM: true },
|
||||
actors: { contents: [] },
|
||||
items: { contents: [talent] },
|
||||
};
|
||||
await migration_1_2_0.run();
|
||||
expect(createEmbeddedDocuments).toHaveBeenCalledTimes(1);
|
||||
const [type, [doc]] = createEmbeddedDocuments.mock.calls[0];
|
||||
expect(type).toBe('ActiveEffect');
|
||||
expect(doc.transfer).toBe(true);
|
||||
expect(doc.changes[0].key).toBe('flags.hbm-rpg-v3.zealRegenBonus');
|
||||
expect(doc.changes[0].value).toBe('1');
|
||||
});
|
||||
|
||||
it('does not attach AE if talent already has one targeting the flag', async () => {
|
||||
const createEmbeddedDocuments = vi.fn(async () => undefined);
|
||||
const talent = {
|
||||
type: 'talent',
|
||||
system: { description: 'regeneracja Zapału' },
|
||||
effects: [{ changes: [{ key: 'flags.hbm-rpg-v3.zealRegenBonus' }] }],
|
||||
createEmbeddedDocuments,
|
||||
};
|
||||
(globalThis as any).game = {
|
||||
...(globalThis as any).game,
|
||||
user: { isGM: true },
|
||||
actors: { contents: [] },
|
||||
items: { contents: [talent] },
|
||||
};
|
||||
await migration_1_2_0.run();
|
||||
expect(createEmbeddedDocuments).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('backfills NPC mana/zeal/blood attributes when missing', async () => {
|
||||
const update = vi.fn(async () => undefined);
|
||||
const npc = {
|
||||
type: 'npc',
|
||||
items: [],
|
||||
effects: [],
|
||||
system: { attributes: {} },
|
||||
update,
|
||||
};
|
||||
(globalThis as any).game = {
|
||||
...(globalThis as any).game,
|
||||
user: { isGM: true },
|
||||
actors: { contents: [npc] },
|
||||
items: { contents: [] },
|
||||
};
|
||||
await migration_1_2_0.run();
|
||||
expect(update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
'system.attributes.mana': expect.any(Object),
|
||||
'system.attributes.zeal': expect.any(Object),
|
||||
'system.attributes.blood': expect.any(Object),
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseTalents } from '../../scripts/parsers/talent-parser';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('talent-parser', () => {
|
||||
it('parses talents from all 4 source books and stamps sourceModule', async () => {
|
||||
const ctx = {
|
||||
repoRoot: resolve(__dirname, '../../../..'),
|
||||
idOverrides: {},
|
||||
strict: false,
|
||||
} as any;
|
||||
|
||||
const docs = await parseTalents(ctx);
|
||||
expect(docs.length).toBeGreaterThan(20);
|
||||
|
||||
// Sanity: each doc has a Polish name and proper subType.
|
||||
for (const d of docs) {
|
||||
expect(d.subType).toBe('talent');
|
||||
expect(d.name).toBeTruthy();
|
||||
expect(d.pack).toMatch(/^talents/);
|
||||
}
|
||||
|
||||
const blood = docs.filter((d) => d.pack === 'talents-blood');
|
||||
const abyss = docs.filter((d) => d.pack === 'talents-eldritch');
|
||||
const core = docs.filter((d) => d.pack === 'talents');
|
||||
|
||||
expect(core.length).toBeGreaterThan(0);
|
||||
expect(blood.length).toBeGreaterThan(0);
|
||||
expect(abyss.length).toBeGreaterThan(0);
|
||||
|
||||
// sourceModule flag is stamped on sub-pack talents only.
|
||||
for (const d of blood) expect((d.flags as any)?.sourceModule).toBe('arcanum-sanguinis');
|
||||
for (const d of abyss) expect((d.flags as any)?.sourceModule).toBe('abyss-curse');
|
||||
for (const d of core) expect(d.flags).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Vitest setup — stubs out Foundry globals that our modules touch on import.
|
||||
* Each test can override these by reaching into globalThis directly.
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Hooks
|
||||
(globalThis as any).__SYSTEM_ID__ = 'hbm-rpg-v3';
|
||||
(globalThis as any).Hooks = {
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
off: vi.fn(),
|
||||
callAll: vi.fn(),
|
||||
call: vi.fn(),
|
||||
};
|
||||
|
||||
// game
|
||||
(globalThis as any).game = {
|
||||
user: { isGM: true, targets: new Set(), character: null },
|
||||
i18n: { localize: (k: string) => k, format: (k: string) => k },
|
||||
settings: {
|
||||
get: vi.fn(() => '0.0.0'),
|
||||
set: vi.fn(async () => undefined),
|
||||
register: vi.fn(),
|
||||
},
|
||||
items: { contents: [] },
|
||||
actors: { contents: [] },
|
||||
system: { version: '1.2.0' },
|
||||
hbm: undefined,
|
||||
};
|
||||
|
||||
// CONFIG
|
||||
(globalThis as any).CONFIG = {
|
||||
Combat: { initiative: { formula: '2d6', decimals: 0 } },
|
||||
Dice: { rolls: [] },
|
||||
Actor: { dataModels: {} },
|
||||
Item: { dataModels: {} },
|
||||
statusEffects: [],
|
||||
};
|
||||
|
||||
// foundry namespace (minimal)
|
||||
(globalThis as any).foundry = {
|
||||
utils: {
|
||||
getProperty: (obj: any, path: string) => path.split('.').reduce((o, k) => o?.[k], obj),
|
||||
setProperty: (obj: any, path: string, value: any) => {
|
||||
const parts = path.split('.');
|
||||
const last = parts.pop()!;
|
||||
const tgt = parts.reduce((o, k) => (o[k] ??= {}), obj);
|
||||
tgt[last] = value;
|
||||
return true;
|
||||
},
|
||||
deepClone: <T>(x: T): T => JSON.parse(JSON.stringify(x)),
|
||||
mergeObject: (a: any, b: any) => Object.assign(a, b),
|
||||
},
|
||||
abstract: {
|
||||
TypeDataModel: class {},
|
||||
},
|
||||
applications: {
|
||||
api: {
|
||||
HandlebarsApplicationMixin: <T extends abstract new (...args: any[]) => any>(base: T) => base,
|
||||
},
|
||||
sheets: {
|
||||
ActorSheetV2: class {},
|
||||
ItemSheetV2: class {},
|
||||
},
|
||||
handlebars: {
|
||||
loadTemplates: vi.fn(async () => []),
|
||||
renderTemplate: vi.fn(async () => ''),
|
||||
},
|
||||
},
|
||||
appv1: { sheets: {} },
|
||||
documents: { collections: {} },
|
||||
};
|
||||
|
||||
// ChatMessage stub
|
||||
(globalThis as any).ChatMessage = {
|
||||
create: vi.fn(async () => undefined),
|
||||
getSpeaker: vi.fn(() => ({})),
|
||||
getWhisperRecipients: vi.fn(() => []),
|
||||
};
|
||||
|
||||
// ui notifications
|
||||
(globalThis as any).ui = {
|
||||
notifications: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
// Roll stub — pool roll counting successes >= threshold
|
||||
class StubRoll {
|
||||
formula: string;
|
||||
data: any;
|
||||
total = 0;
|
||||
ts: { isSuccess: boolean; successes: number };
|
||||
constructor(formula: string, data?: any) {
|
||||
this.formula = formula;
|
||||
this.data = data;
|
||||
this.ts = { isSuccess: false, successes: 0 };
|
||||
}
|
||||
async evaluate() {
|
||||
if (this.formula && this.formula.includes('1d6')) {
|
||||
this.total = 4 + (this.data?.endurance ?? 0);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
async toMessage() { return undefined; }
|
||||
}
|
||||
(globalThis as any).Roll = StubRoll;
|
||||
(global as any).Roll = StubRoll;
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).Roll = StubRoll;
|
||||
}
|
||||
Reference in New Issue
Block a user