feat: defer advanced build calculations
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createDraft } from './loadout.js';
|
||||
import { summarizeLoadout, type LoadoutCatalog } from './loadout-summary.js';
|
||||
|
||||
const attribute = (
|
||||
id: string,
|
||||
name: string,
|
||||
options: {
|
||||
unit?: string;
|
||||
calculationType?: string;
|
||||
cap?: number | null;
|
||||
} = {},
|
||||
) => ({
|
||||
id,
|
||||
name,
|
||||
category: 'Combat',
|
||||
unit: options.unit ?? '',
|
||||
calculation_type: options.calculationType ?? 'Additive',
|
||||
cap_value: options.cap ?? null,
|
||||
});
|
||||
|
||||
const catalog = (overrides: Partial<LoadoutCatalog> = {}): LoadoutCatalog => ({
|
||||
attributes: [],
|
||||
gear: [],
|
||||
gearAugments: [],
|
||||
brands: [],
|
||||
weapons: [],
|
||||
talents: [],
|
||||
chipSets: [],
|
||||
protocols: [],
|
||||
specializations: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const stat = (summary: ReturnType<typeof summarizeLoadout>, id: string) =>
|
||||
summary.stats.find((entry) => entry.id === id);
|
||||
|
||||
describe('summarizeLoadout', () => {
|
||||
it('returns an empty deterministic summary for a partial draft', () => {
|
||||
expect(summarizeLoadout(createDraft(), catalog())).toEqual({
|
||||
stats: [],
|
||||
activeBonuses: [],
|
||||
equippedTalents: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores saved rolls that do not reference a known Attribute', () => {
|
||||
const draft = createDraft();
|
||||
draft.weapons[0].itemId = 'weapon';
|
||||
draft.weapons[0].rolls[0] = { statId: 'unknown', value: 999 };
|
||||
|
||||
const summary = summarizeLoadout(
|
||||
draft,
|
||||
catalog({
|
||||
weapons: [
|
||||
{
|
||||
id: 'weapon',
|
||||
name: 'Test Weapon',
|
||||
exotic_talent_id: null,
|
||||
baseBonuses: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(summary.stats).toEqual([]);
|
||||
});
|
||||
|
||||
it('combines additive and multiplicative Attributes, applies caps, and counts distinct sources', () => {
|
||||
const draft = createDraft();
|
||||
draft.weapons[0].itemId = 'weapon';
|
||||
draft.weapons[0].rolls = [
|
||||
{ statId: 'additive', value: 5 },
|
||||
{ statId: 'multiplicative', value: 20 },
|
||||
];
|
||||
|
||||
const summary = summarizeLoadout(
|
||||
draft,
|
||||
catalog({
|
||||
attributes: [
|
||||
attribute('additive', 'Additive'),
|
||||
attribute('multiplicative', 'Multiplicative', {
|
||||
unit: '%',
|
||||
calculationType: 'Multiplicative',
|
||||
}),
|
||||
attribute('capped', 'Capped', { cap: 25 }),
|
||||
],
|
||||
weapons: [
|
||||
{
|
||||
id: 'weapon',
|
||||
name: 'Test Weapon',
|
||||
exotic_talent_id: null,
|
||||
baseBonuses: [
|
||||
{ stat_attribute_id: 'additive', value: 10 },
|
||||
{ stat_attribute_id: 'multiplicative', value: 10 },
|
||||
{ stat_attribute_id: 'capped', value: 30 },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(stat(summary, 'additive')).toEqual({
|
||||
id: 'additive',
|
||||
value: 15,
|
||||
sourceCount: 2,
|
||||
});
|
||||
expect(stat(summary, 'multiplicative')?.value).toBeCloseTo(32);
|
||||
expect(stat(summary, 'multiplicative')?.sourceCount).toBe(2);
|
||||
expect(stat(summary, 'capped')).toEqual({
|
||||
id: 'capped',
|
||||
value: 25,
|
||||
sourceCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('activates every earned set bonus and records it as a stat source', () => {
|
||||
const draft = createDraft();
|
||||
for (const slot of ['Holster', 'Gloves'] as const) {
|
||||
draft.gear[slot].itemId = `${slot}-gear`;
|
||||
draft.gear[slot].brandId = 'brand';
|
||||
}
|
||||
|
||||
const summary = summarizeLoadout(
|
||||
draft,
|
||||
catalog({
|
||||
attributes: [attribute('set-stat', 'Set Stat', { unit: '%' })],
|
||||
gear: [
|
||||
{ id: 'Holster-gear', tier: 0, base_armor_min: null },
|
||||
{ id: 'Gloves-gear', tier: 0, base_armor_min: null },
|
||||
],
|
||||
brands: [
|
||||
{
|
||||
id: 'brand',
|
||||
name: 'Determinist',
|
||||
effects: [
|
||||
{
|
||||
pieces_required: 1,
|
||||
stat_attribute_id: 'set-stat',
|
||||
value: 5,
|
||||
},
|
||||
{
|
||||
pieces_required: 2,
|
||||
stat_attribute_id: 'set-stat',
|
||||
value: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(stat(summary, 'set-stat')).toEqual({
|
||||
id: 'set-stat',
|
||||
value: 15,
|
||||
sourceCount: 2,
|
||||
});
|
||||
expect(summary.activeBonuses).toEqual([
|
||||
expect.objectContaining({ pieces: 1, count: 2, value: 5 }),
|
||||
expect.objectContaining({ pieces: 2, count: 2, value: 10 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('interpolates Skill Chip bonuses and Talent descriptions from High-end count', () => {
|
||||
const draft = createDraft();
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
draft.chips[index].setId = 'chip-set';
|
||||
draft.chips[index].quality = index === 0 ? 'High-end' : 'Superior';
|
||||
}
|
||||
|
||||
const summary = summarizeLoadout(
|
||||
draft,
|
||||
catalog({
|
||||
attributes: [attribute('skill-damage', 'Skill Damage', { unit: '%' })],
|
||||
chipSets: [
|
||||
{
|
||||
id: 'chip-set',
|
||||
name: 'Signal',
|
||||
specialization_ability_id: 'ability',
|
||||
twoPiece: {
|
||||
stat_attribute_id: 'skill-damage',
|
||||
min_value: 10,
|
||||
max_value: 40,
|
||||
},
|
||||
talent: {
|
||||
name: 'Signal Talent',
|
||||
description_template: 'Gain {1} Skill Damage.',
|
||||
effects: [
|
||||
{
|
||||
stat_attribute_id: 'skill-damage',
|
||||
min_value: 15,
|
||||
max_value: 45,
|
||||
},
|
||||
],
|
||||
rule: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(stat(summary, 'skill-damage')).toEqual({
|
||||
id: 'skill-damage',
|
||||
value: 20,
|
||||
sourceCount: 1,
|
||||
});
|
||||
expect(summary.equippedTalents).toEqual([
|
||||
expect.objectContaining({
|
||||
key: 'chips:chip-set',
|
||||
tier: 1,
|
||||
abilityId: 'ability',
|
||||
description: 'Gain 25% Skill Damage.',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('scales Protocol primary stats and Talent descriptions independently', () => {
|
||||
const draft = createDraft();
|
||||
draft.protocol = { itemId: 'protocol', level: 50, talentUpgrades: 5 };
|
||||
|
||||
const summary = summarizeLoadout(
|
||||
draft,
|
||||
catalog({
|
||||
attributes: [
|
||||
attribute('health', 'Health'),
|
||||
attribute('healing', 'Healing', { unit: '%' }),
|
||||
],
|
||||
protocols: [
|
||||
{
|
||||
id: 'protocol',
|
||||
name: 'Survival',
|
||||
primaryEffect: {
|
||||
stat_attribute_id: 'health',
|
||||
base_value: 100,
|
||||
},
|
||||
secondaryEffects: [{ stat_attribute_id: 'healing', value: 5 }],
|
||||
talent: {
|
||||
name: 'Recovery',
|
||||
description_template: 'Restore {1}.',
|
||||
effects: [{ stat_attribute_id: 'healing', base_value: 2.5 }],
|
||||
rule: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(stat(summary, 'health')?.value).toBe(200);
|
||||
expect(stat(summary, 'healing')?.value).toBe(5);
|
||||
expect(summary.equippedTalents[0]).toMatchObject({
|
||||
key: 'protocol:protocol',
|
||||
tier: 5,
|
||||
description: 'Restore 5%.',
|
||||
});
|
||||
});
|
||||
|
||||
it('applies static specialization rank effects but excludes combat mechanic outcomes', () => {
|
||||
const draft = createDraft();
|
||||
draft.specialization.focusId = 'focus';
|
||||
draft.specialization.nodeRanks.node = 2;
|
||||
|
||||
const summary = summarizeLoadout(
|
||||
draft,
|
||||
catalog({
|
||||
attributes: [
|
||||
attribute('static', 'Static'),
|
||||
attribute('mechanic', 'Mechanic'),
|
||||
],
|
||||
specializations: [
|
||||
{
|
||||
id: 'specialization',
|
||||
focuses: [
|
||||
{
|
||||
id: 'focus',
|
||||
nodes: [
|
||||
{
|
||||
id: 'node',
|
||||
name: 'Steady Hand',
|
||||
ranks: [
|
||||
{
|
||||
rank: 2,
|
||||
effects: [
|
||||
{
|
||||
effect_type: 'Attribute',
|
||||
stat_attribute_id: 'static',
|
||||
value: 12,
|
||||
},
|
||||
],
|
||||
combatEffects: [
|
||||
{
|
||||
rule: {
|
||||
clauses: [
|
||||
{
|
||||
outcomes: [
|
||||
{
|
||||
kind: 'attribute_modifier',
|
||||
attributeId: 'mechanic',
|
||||
value: 99,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(stat(summary, 'static')).toEqual({
|
||||
id: 'static',
|
||||
value: 12,
|
||||
sourceCount: 1,
|
||||
});
|
||||
expect(stat(summary, 'mechanic')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('renders the selected Talent tier without applying its mechanic outcomes to stats', () => {
|
||||
const draft = createDraft();
|
||||
draft.weapons[0].itemId = 'weapon';
|
||||
draft.weapons[0].tier = 2;
|
||||
draft.weapons[0].talent1Id = 'talent';
|
||||
|
||||
const rule = {
|
||||
clauses: [
|
||||
{
|
||||
outcomes: [
|
||||
{
|
||||
kind: 'attribute_modifier',
|
||||
attributeId: 'talent-stat',
|
||||
value: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const summary = summarizeLoadout(
|
||||
draft,
|
||||
catalog({
|
||||
attributes: [
|
||||
attribute('talent-stat', 'Talent Stat', { unit: '%' }),
|
||||
attribute('shots', 'Shots', { unit: 'Shots' }),
|
||||
],
|
||||
weapons: [
|
||||
{
|
||||
id: 'weapon',
|
||||
name: 'Test Weapon',
|
||||
exotic_talent_id: null,
|
||||
baseBonuses: [],
|
||||
},
|
||||
],
|
||||
talents: [
|
||||
{
|
||||
id: 'talent',
|
||||
name: 'Measured',
|
||||
template: 'Gain {1} for {2}.',
|
||||
tiers: [
|
||||
{
|
||||
tier: 'I',
|
||||
effects: [
|
||||
{ stat_attribute_id: 'talent-stat', value: 10 },
|
||||
{ stat_attribute_id: 'shots', value: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
tier: 'II',
|
||||
effects: [
|
||||
{ stat_attribute_id: 'talent-stat', value: 20 },
|
||||
{ stat_attribute_id: 'shots', value: 3 },
|
||||
],
|
||||
},
|
||||
],
|
||||
rule,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(summary.equippedTalents).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'talent',
|
||||
tier: 2,
|
||||
description: 'Gain 20% for 3 Shots.',
|
||||
rule,
|
||||
}),
|
||||
]);
|
||||
expect(stat(summary, 'talent-stat')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses the authored tier description when a Talent has no template row', () => {
|
||||
const draft = createDraft();
|
||||
draft.weapons[0].itemId = 'weapon';
|
||||
draft.weapons[0].talent1Id = 'talent';
|
||||
|
||||
const summary = summarizeLoadout(
|
||||
draft,
|
||||
catalog({
|
||||
attributes: [attribute('bonus', 'Bonus', { unit: '%' })],
|
||||
weapons: [
|
||||
{
|
||||
id: 'weapon',
|
||||
name: 'Test Weapon',
|
||||
exotic_talent_id: null,
|
||||
baseBonuses: [],
|
||||
},
|
||||
],
|
||||
talents: [
|
||||
{
|
||||
id: 'talent',
|
||||
name: 'Measured',
|
||||
template: '',
|
||||
tiers: [
|
||||
{
|
||||
tier: 'I',
|
||||
description: 'Gain {1} while active.',
|
||||
effects: [{ stat_attribute_id: 'bonus', value: 10 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(summary.equippedTalents[0]?.description).toBe(
|
||||
'Gain 10% while active.',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user