import { gearSlots, scaled, type LoadoutDraft, type TalentTier, } from '$lib/builder/loadout'; export type LoadoutCatalog = { attributes: any[]; gear: any[]; gearAugments: any[]; brands: any[]; weapons: any[]; talents: any[]; skillChipProfile?: { primaryOptions: any[]; secondaryOptions: any[]; }; chipSets: any[]; protocols: any[]; specializations: any[]; }; export type LoadoutSummary = { stats: Array<{ id: string; value: number; sourceCount: number }>; abilityStats: Array<{ abilityId: string; stats: Array<{ id: string; value: number; sourceCount: number; sources: Array<{ source: string; chipIndex: number; chipSetId: string; value: number; }>; }>; }>; activeBonuses: any[]; equippedTalents: any[]; }; const coreKeys = ['firepower', 'toughness', 'engineering'] as const; export function selectedFocus(draft: LoadoutDraft, catalog: LoadoutCatalog) { return catalog.specializations .flatMap((specialization) => specialization.focuses.map((focus: any) => ({ ...focus, specialization, })), ) .find((focus: any) => focus.id === draft.specialization.focusId); } export function selectedNodeRanks( draft: LoadoutDraft, catalog: LoadoutCatalog, ) { const focus = selectedFocus(draft, catalog); return ( focus?.nodes.flatMap((node: any) => { const selectedRank = Number(draft.specialization.nodeRanks[node.id] ?? 0); return selectedRank ? [ { node, rank: node.ranks.find( (entry: any) => Number(entry.rank) === selectedRank, ), }, ] : []; }) ?? [] ); } export function catalogAttribute(catalog: LoadoutCatalog, id: string) { return catalog.attributes.find((item) => item.id === id); } export function applyAttributeValue( catalog: LoadoutCatalog, current: number, statId: string, incoming: number, ) { const stat = catalogAttribute(catalog, statId); const combined = stat?.calculation_type === 'Multiplicative' && stat?.unit === '%' ? ((1 + current / 100) * (1 + incoming / 100) - 1) * 100 : current + incoming; return stat?.cap_value === null || stat?.cap_value === undefined ? combined : Math.min(combined, Number(stat.cap_value)); } function unitValue(catalog: LoadoutCatalog, value: unknown, statId: string) { const numeric = Number(value); const rendered = Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2).replace(/\.?0+$/, ''); const unit = catalogAttribute(catalog, statId)?.unit ?? ''; return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`; } function talentTier( catalog: LoadoutCatalog, talentId: string, tier: TalentTier, ) { const item = catalog.talents.find((entry) => entry.id === talentId); return item?.tiers.find( (entry: any) => entry.tier === (tier === 2 ? 'II' : 'I'), ); } function talentDescription( catalog: LoadoutCatalog, talentId: string, tier: TalentTier, ) { const item = catalog.talents.find((entry) => entry.id === talentId); const configured = talentTier(catalog, talentId, tier); if (!item || !configured) return ''; const template = String(item.template || configured.description || item.name); return template.replace(/\{(\d+)\}/g, (placeholder, raw) => { const effect = configured.effects[Number(raw) - 1]; return effect ? unitValue(catalog, effect.value, effect.stat_attribute_id) : placeholder; }); } export function summarizeLoadout( draft: LoadoutDraft, catalog: LoadoutCatalog, ): LoadoutSummary { const values = new Map(); const sources = new Map>(); const add = (statId: string, value: unknown, source: string) => { if ( !statId || !catalogAttribute(catalog, statId) || !Number.isFinite(Number(value)) ) return; values.set( statId, applyAttributeValue( catalog, values.get(statId) ?? 0, statId, Number(value), ), ); if (!sources.has(statId)) sources.set(statId, new Set()); sources.get(statId)!.add(source); }; const byName = (name: string) => catalog.attributes.find( (item) => item.name.toLocaleLowerCase() === name.toLocaleLowerCase(), )?.id ?? ''; const brandCounts = new Map(); const activeBonuses: any[] = []; const equippedTalents: any[] = []; const abilityValues = new Map>(); const abilitySources = new Map< string, Map< string, Array<{ source: string; chipIndex: number; chipSetId: string; value: number; }> > >(); const addAbilityStat = ( abilityId: string, statId: string, value: unknown, chipIndex: number, chipSetId: string, ) => { if ( !abilityId || !statId || !catalogAttribute(catalog, statId) || !Number.isFinite(Number(value)) ) return; if (!abilityValues.has(abilityId)) abilityValues.set(abilityId, new Map()); const valuesForAbility = abilityValues.get(abilityId)!; valuesForAbility.set( statId, applyAttributeValue( catalog, valuesForAbility.get(statId) ?? 0, statId, Number(value), ), ); if (!abilitySources.has(abilityId)) abilitySources.set(abilityId, new Map()); const sourcesForAbility = abilitySources.get(abilityId)!; if (!sourcesForAbility.has(statId)) sourcesForAbility.set(statId, []); sourcesForAbility.get(statId)!.push({ source: `Skill Chip ${chipIndex + 1} primary`, chipIndex, chipSetId, value: Number(value), }); }; for (const slot of gearSlots) { const selection = draft.gear[slot]; const item = catalog.gear.find((entry) => entry.id === selection.itemId); if (!item) continue; const enabledAugments = catalog.gearAugments.filter( (entry) => entry.gear_slot === slot && selection.augmentSlots.includes(Number(entry.augment_slot)), ); const basePercentage = enabledAugments .filter((entry) => entry.effect_type === 'Base Percentage') .reduce((total, entry) => total + Number(entry.value), 0); add( byName('Health'), selection.health * (1 + basePercentage / 100), `${slot} base`, ); if (item.base_armor_min !== null) add( byName('Armor'), selection.armor * (1 + basePercentage / 100), `${slot} base`, ); for (const roll of selection.rolls) add(roll.statId, roll.value, `${slot} roll`); for (const key of coreKeys) add(byName(key), selection[key], `${slot} core`); if (selection.brandId) brandCounts.set( selection.brandId, (brandCounts.get(selection.brandId) ?? 0) + 1, ); for (const augment of enabledAugments.filter( (entry) => entry.effect_type === 'Custom', )) { add( augment.stat_attribute_id, augment.value, `${slot} augment ${augment.augment_slot}`, ); } if (selection.talentId) { equippedTalents.push({ key: `gear:${slot}:${selection.talentId}`, id: selection.talentId, rule: catalog.talents.find((entry) => entry.id === selection.talentId) ?.rule ?? null, tier: selection.talentTier, tierLabel: selection.talentTier === 2 ? 'TIER II' : 'TIER I', isExotic: false, source: slot, description: talentDescription( catalog, selection.talentId, selection.talentTier, ), }); } } for (const [brandId, count] of brandCounts) { const set = catalog.brands.find((entry) => entry.id === brandId); for (const effect of set?.effects.filter( (entry: any) => Number(entry.pieces_required) <= count, ) ?? []) { add( effect.stat_attribute_id, effect.value, `${set.name} ${effect.pieces_required}-piece`, ); activeBonuses.push({ name: set.name, pieces: effect.pieces_required, count, statId: effect.stat_attribute_id, value: effect.value, }); } } for (let index = 0; index < 2; index += 1) { const selection = draft.weapons[index]; const item = catalog.weapons.find((entry) => entry.id === selection.itemId); if (!item) continue; for (const name of ['Firepower', 'Toughness', 'Engineering'] as const) { add( byName(name), selection[name.toLowerCase() as 'firepower'], `${index ? 'Secondary' : 'Primary'} weapon`, ); } for (const bonus of item.baseBonuses ?? []) add(bonus.stat_attribute_id, bonus.value, `${item.name} base bonus`); for (const roll of selection.rolls) add( roll.statId, roll.value, `${index ? 'Secondary' : 'Primary'} weapon roll`, ); const equippedWeaponTalents = [ { id: selection.talent1Id, tier: selection.talent1Tier, isExotic: false, }, { id: selection.talent2Id, tier: selection.talent2Tier, isExotic: false, }, { id: item.exotic_talent_id, tier: selection.exoticTalentTier, isExotic: true, }, ].filter((entry) => entry.id); for (const equippedTalent of equippedWeaponTalents) { equippedTalents.push({ key: `weapon:${index}:${equippedTalent.id}`, id: equippedTalent.id, rule: catalog.talents.find((entry) => entry.id === equippedTalent.id) ?.rule ?? null, tier: equippedTalent.tier, tierLabel: equippedTalent.tier === 2 ? 'TIER II' : 'TIER I', isExotic: equippedTalent.isExotic, source: index ? 'Secondary weapon' : 'Primary weapon', description: talentDescription( catalog, equippedTalent.id, equippedTalent.tier, ), }); } } const chipGroups = new Map< string, { count: number; highEnd: number; item: any } >(); const validChipRoll = ( kind: 'primaryOptions' | 'secondaryOptions', quality: string, roll: { statId: string; value: number }, ) => { if (!catalog.skillChipProfile) return true; const range = catalog.skillChipProfile[kind].find( (option) => option.statId === roll.statId, )?.ranges?.[quality]; return ( range && roll.value >= Number(range.min) && roll.value <= Number(range.max) ); }; for (let index = 0; index < 9; index += 1) { const selection = draft.chips[index]; const item = catalog.chipSets.find((entry) => entry.id === selection.setId); if (!item) continue; if (validChipRoll('primaryOptions', selection.quality, selection.primary)) addAbilityStat( String(item.specialization_ability_id ?? ''), selection.primary.statId, selection.primary.value, index, String(item.id), ); for (const roll of selection.secondary) if (validChipRoll('secondaryOptions', selection.quality, roll)) add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`); const group = chipGroups.get(item.id) ?? { count: 0, highEnd: 0, item }; group.count += 1; if (selection.quality === 'High-end') group.highEnd += 1; chipGroups.set(item.id, group); } for (const group of chipGroups.values()) { if (group.count >= 2 && group.item.twoPiece) { const value = scaled( group.item.twoPiece.min_value, group.item.twoPiece.max_value, group.highEnd, 3, ); add( group.item.twoPiece.stat_attribute_id, value, `${group.item.name} 2-piece`, ); activeBonuses.push({ name: group.item.name, pieces: 2, count: group.count, statId: group.item.twoPiece.stat_attribute_id, value, }); } if (group.count === 3 && group.item.talent) { equippedTalents.push({ key: `chips:${group.item.id}`, id: '', custom: group.item.talent, rule: group.item.talent.rule ?? null, tier: group.highEnd, abilityId: group.item.specialization_ability_id ?? null, source: '3 Skill Chips', description: group.item.talent.description_template.replace( /\{(\d+)\}/g, (placeholder: string, raw: string) => { const effect = group.item.talent.effects[Number(raw) - 1]; return effect ? unitValue( catalog, scaled(effect.min_value, effect.max_value, group.highEnd, 3), effect.stat_attribute_id, ) : placeholder; }, ), }); } } for (const { node, rank } of selectedNodeRanks(draft, catalog)) { for (const effect of rank?.effects ?? []) { if (effect.effect_type === 'Attribute') add( effect.stat_attribute_id, effect.value, `${node.name} rank ${rank.rank}`, ); } } const protocol = catalog.protocols.find( (entry) => entry.id === draft.protocol.itemId, ); if (protocol) { if (protocol.primaryEffect) { add( protocol.primaryEffect.stat_attribute_id, Number(protocol.primaryEffect.base_value) * (1 + (draft.protocol.level - 1) / 49), `${protocol.name} primary`, ); } for (const effect of protocol.secondaryEffects ?? []) { add(effect.stat_attribute_id, effect.value, `${protocol.name} secondary`); } if (protocol.talent) { equippedTalents.push({ key: `protocol:${protocol.id}`, id: '', custom: protocol.talent, rule: protocol.talent.rule ?? null, tier: draft.protocol.talentUpgrades, source: 'OS Protocol', description: protocol.talent.description_template.replace( /\{(\d+)\}/g, (placeholder: string, raw: string) => { const effect = protocol.talent.effects[Number(raw) - 1]; return effect ? unitValue( catalog, Number(effect.base_value) * (1 + draft.protocol.talentUpgrades / 5), effect.stat_attribute_id, ) : placeholder; }, ), }); } } return { stats: [...values] .map(([id, value]) => ({ id, value, sourceCount: sources.get(id)?.size ?? 0, })) .sort( (left, right) => (catalogAttribute(catalog, left.id)?.category ?? '').localeCompare( catalogAttribute(catalog, right.id)?.category ?? '', ) || (catalogAttribute(catalog, left.id)?.name ?? '').localeCompare( catalogAttribute(catalog, right.id)?.name ?? '', ), ), abilityStats: [...abilityValues].map(([abilityId, abilityStatValues]) => ({ abilityId, stats: [...abilityStatValues] .map(([id, value]) => { const statSources = abilitySources.get(abilityId)?.get(id) ?? []; return { id, value, sourceCount: statSources.length, sources: statSources, }; }) .sort( (left, right) => (catalogAttribute(catalog, left.id)?.category ?? '').localeCompare( catalogAttribute(catalog, right.id)?.category ?? '', ) || (catalogAttribute(catalog, left.id)?.name ?? '').localeCompare( catalogAttribute(catalog, right.id)?.name ?? '', ), ), })), activeBonuses, equippedTalents, }; }