Files
shd.build/src/lib/builder/loadout-summary.test.ts
T
jb 4777a3abd5
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 44s
Validate and deploy staging / deploy (push) Successful in 1m14s
feat: refine builder skill chip flows
2026-08-06 01:00:15 -04:00

687 lines
18 KiB
TypeScript

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: [],
abilityStats: [],
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:ability',
tier: 1,
abilityId: 'ability',
description: 'Gain 25% Skill Damage.',
}),
]);
});
it('keeps Skill Chip primaries ability-scoped while adding secondaries to Agent stats', () => {
const draft = createDraft();
draft.specialization.focusId = 'focus-1';
draft.chips[0] = {
setId: 'chip-set',
quality: 'Superior',
primary: { statId: 'skill-duration', value: 12 },
secondary: [
{ statId: 'agent-stat', value: 4 },
{ statId: 'other-secondary', value: 6 },
],
};
const summary = summarizeLoadout(
draft,
catalog({
attributes: [
attribute('skill-duration', 'Skill Duration', { unit: '%' }),
attribute('agent-stat', 'Agent Stat', { unit: '%' }),
attribute('other-secondary', 'Other Secondary', { unit: '%' }),
],
chipSets: [
{
id: 'chip-set',
name: 'Signal',
specialization_ability_id: 'ability-2',
compatibleAbilityIds: ['ability-2', 'ability-1'],
twoPiece: null,
talent: null,
},
],
specializations: [
{
id: 'specialization-1',
focuses: [
{
id: 'focus-1',
abilities: [{ id: 'ability-1' }],
nodes: [],
},
],
},
],
}),
);
expect(stat(summary, 'skill-duration')).toBeUndefined();
expect(stat(summary, 'agent-stat')?.value).toBe(4);
expect(stat(summary, 'other-secondary')?.value).toBe(6);
expect(summary.abilityStats).toEqual([
{
abilityId: 'ability-1',
stats: [
{
id: 'skill-duration',
value: 12,
sourceCount: 1,
sources: [
{
source: 'Skill Chip 1 primary',
chipIndex: 0,
chipSetId: 'chip-set',
value: 12,
},
],
},
],
},
]);
});
it('ignores a shared Skill Chip after its ability link is removed', () => {
const draft = createDraft();
draft.specialization.focusId = 'focus-1';
draft.chips[0] = {
setId: 'chip-set',
quality: 'Standard',
primary: { statId: 'skill-duration', value: 12 },
secondary: [],
};
const summary = summarizeLoadout(
draft,
catalog({
attributes: [
attribute('skill-duration', 'Skill Duration', { unit: '%' }),
],
chipSets: [
{
id: 'chip-set',
name: 'Signal',
specialization_ability_id: 'ability-2',
compatibleAbilityIds: ['ability-2'],
twoPiece: null,
talent: null,
},
],
specializations: [
{
id: 'specialization-1',
focuses: [
{
id: 'focus-1',
abilities: [{ id: 'ability-1' }],
nodes: [],
},
],
},
],
}),
);
expect(summary.abilityStats).toEqual([]);
});
it.each([
[0, 10],
[1, 20],
[2, 30],
[3, 40],
])(
'scales a three-chip 2-piece bonus with %i High-end chips',
(highEndCount, expectedValue) => {
const draft = createDraft();
for (let index = 0; index < 3; index += 1) {
draft.chips[index].setId = 'chip-set';
draft.chips[index].quality =
index < highEndCount ? 'High-end' : 'Standard';
}
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: null,
},
],
}),
);
expect(stat(summary, 'skill-damage')?.value).toBe(expectedValue);
expect(summary.activeBonuses[0]).toMatchObject({
count: 3,
value: expectedValue,
});
},
);
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('uses a gear Talent tier independently from the gear tier', () => {
const draft = createDraft();
draft.gear.Holster.itemId = 'gear';
draft.gear.Holster.talentId = 'talent';
Object.assign(draft.gear.Holster, { talentTier: 2 });
const summary = summarizeLoadout(
draft,
catalog({
attributes: [attribute('bonus', 'Bonus', { unit: '%' })],
gear: [{ id: 'gear', tier: 0, base_armor_min: null }],
talents: [
{
id: 'talent',
name: 'Reinforced',
template: 'Gain {1}.',
tiers: [
{
tier: 'I',
effects: [{ stat_attribute_id: 'bonus', value: 10 }],
},
{
tier: 'II',
effects: [{ stat_attribute_id: 'bonus', value: 20 }],
},
],
},
],
}),
);
expect(summary.equippedTalents).toEqual([
expect.objectContaining({
id: 'talent',
tier: 2,
tierLabel: 'TIER II',
description: 'Gain 20%.',
}),
]);
});
it('uses each weapon Talent tier independently without applying mechanic outcomes to stats', () => {
const draft = createDraft();
draft.weapons[0].itemId = 'weapon';
draft.weapons[0].tier = 2;
draft.weapons[0].talent1Id = 'talent-one';
draft.weapons[0].talent2Id = 'talent-two';
Object.assign(draft.weapons[0], {
talent1Tier: 1,
talent2Tier: 2,
exoticTalentTier: 1,
});
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: 'exotic-talent',
baseBonuses: [],
},
],
talents: [
{
id: 'talent-one',
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,
},
{
id: 'talent-two',
name: 'Calibrated',
template: 'Gain {1}.',
tiers: [
{
tier: 'I',
effects: [{ stat_attribute_id: 'talent-stat', value: 30 }],
},
{
tier: 'II',
effects: [{ stat_attribute_id: 'talent-stat', value: 40 }],
},
],
},
{
id: 'exotic-talent',
name: 'Exotic Talent',
template: 'Gain {1}.',
tiers: [
{
tier: 'I',
effects: [{ stat_attribute_id: 'talent-stat', value: 50 }],
},
{
tier: 'II',
effects: [{ stat_attribute_id: 'talent-stat', value: 60 }],
},
],
},
],
}),
);
expect(summary.equippedTalents).toEqual([
expect.objectContaining({
id: 'talent-one',
tier: 1,
tierLabel: 'TIER I',
description: 'Gain 10% for 2 Shots.',
rule,
}),
expect.objectContaining({
id: 'talent-two',
tier: 2,
tierLabel: 'TIER II',
description: 'Gain 40%.',
}),
expect.objectContaining({
id: 'exotic-talent',
tier: 1,
tierLabel: 'TIER I',
description: 'Gain 50%.',
}),
]);
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.',
);
});
});