687 lines
18 KiB
TypeScript
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.',
|
|
);
|
|
});
|
|
});
|