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 => ({ attributes: [], gear: [], gearAugments: [], brands: [], weapons: [], talents: [], chipSets: [], protocols: [], specializations: [], ...overrides, }); const stat = (summary: ReturnType, 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.', ); }); });