import { calculateCombat, type AttributeBinding, type CalculationStage } from '$lib/calculations/combat'; import { gearSlots, scaled, type LoadoutDraft } from '$lib/builder/loadout'; import type { WeaponCalculationResult } from '$lib/components/builder/WeaponCalculationResult.svelte'; type Catalog = { attributes: any[]; gear: any[]; gearAugments: any[]; brands: any[]; weapons: any[]; talents: any[]; chipSets: any[]; protocols: any[]; specializations: any[]; calculation: { revision: any | null; stages: any[]; profiles: any[] }; }; export type LoadoutSummary = { stats: Array<{ id: string; value: number; sourceCount: number }>; activeBonuses: any[]; equippedTalents: any[]; }; const coreKeys = ['firepower', 'toughness', 'engineering'] as const; function selectedFocus(draft: LoadoutDraft, catalog: Catalog) { return catalog.specializations .flatMap((specialization) => specialization.focuses.map((focus: any) => ({ ...focus, specialization }))) .find((focus: any) => focus.id === draft.specialization.focusId); } function selectedNodeRanks(draft: LoadoutDraft, catalog: Catalog) { 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) }] : []; }) ?? []; } function attribute(catalog: Catalog, id: string) { return catalog.attributes.find((item) => item.id === id); } function unitValue(catalog: Catalog, value: unknown, statId: string) { const numeric = Number(value); const rendered = Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2).replace(/\.?0+$/, ''); const unit = attribute(catalog, statId)?.unit ?? ''; return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`; } function talentTier(catalog: Catalog, talentId: string, tier: number) { const item = catalog.talents.find((entry) => entry.id === talentId); return item?.tiers.find((entry: any) => entry.tier === (tier >= 2 ? 'II' : 'I')) ?? item?.tiers[0]; } function talentDescription(catalog: Catalog, talentId: string, tier: number) { const item = catalog.talents.find((entry) => entry.id === talentId); const configured = talentTier(catalog, talentId, tier); if (!item || !configured) return ''; const template = String(item.template || 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: Catalog): LoadoutSummary { const values = new Map(); const sources = new Map>(); const add = (statId: string, value: unknown, source: string) => { if (!statId || !Number.isFinite(Number(value))) return; const stat = attribute(catalog, statId); const current = values.get(statId) ?? 0; const incoming = Number(value); const combined = stat?.calculation_type === 'Multiplicative' && stat?.unit === '%' ? ((1 + current / 100) * (1 + incoming / 100) - 1) * 100 : current + incoming; values.set( statId, stat?.cap_value === null || stat?.cap_value === undefined ? combined : Math.min(combined, Number(stat.cap_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[] = []; 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, tier: Number(item.tier), source: slot, description: talentDescription(catalog, selection.talentId, Number(item.tier)) }); } } 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`); for (const talentId of [selection.talent1Id, selection.talent2Id, item.exotic_talent_id].filter(Boolean)) { equippedTalents.push({ key: `weapon:${index}:${talentId}`, id: talentId, tier: selection.tier, source: index ? 'Secondary weapon' : 'Primary weapon', description: talentDescription(catalog, talentId, selection.tier) }); } } const chipGroups = new Map(); 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; add(selection.primary.statId, selection.primary.value, `Skill Chip ${index + 1} primary`); for (const roll of selection.secondary) 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, tier: group.highEnd, 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, 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; }) }); } } for (const equipped of equippedTalents) { if (!draft.activeTalents.includes(equipped.key)) continue; const effects = equipped.custom?.effects ?? talentTier(catalog, equipped.id, equipped.tier)?.effects ?? []; for (const effect of effects) { const value = equipped.key.startsWith('chips:') ? scaled(effect.min_value, effect.max_value, equipped.tier, 3) : equipped.key.startsWith('protocol:') ? Number(effect.base_value) * (1 + equipped.tier / 5) : effect.value; add(effect.stat_attribute_id, value, `${equipped.source} talent`); } } return { stats: [...values] .map(([id, value]) => ({ id, value, sourceCount: sources.get(id)?.size ?? 0 })) .sort((left, right) => (attribute(catalog, left.id)?.category ?? '').localeCompare(attribute(catalog, right.id)?.category ?? '') || (attribute(catalog, left.id)?.name ?? '').localeCompare(attribute(catalog, right.id)?.name ?? '') ), activeBonuses, equippedTalents }; } function calculationStage(catalog: Catalog, stageKey: string): CalculationStage | null { if (['flat_damage', 'weapon_damage', 'critical_chance', 'critical_damage', 'headshot_chance', 'headshot_damage', 'final_damage'].includes(stageKey)) { return stageKey as CalculationStage; } const stage = catalog.calculation.stages.find((item) => item.stage_key === stageKey); if (!stage) return null; if (stage.operator === 'flat_add') return 'flat_damage'; if (stage.operator === 'additive_percent') return 'weapon_damage'; if (stage.operator === 'multiplicative_percent') return 'final_damage'; if (stage.operator === 'expected_critical') return stageKey.includes('chance') ? 'critical_chance' : 'critical_damage'; if (stage.operator === 'expected_headshot') return stageKey.includes('chance') ? 'headshot_chance' : 'headshot_damage'; return null; } function calculationBindings(catalog: Catalog, weapon: any): AttributeBinding[] { return catalog.calculation.profiles .filter((item) => item.weapon_category === '*' || item.weapon_category === weapon?.category) .flatMap((profile) => { const stage = calculationStage(catalog, String(profile.stage_key)); return stage ? [{ attributeId: String(profile.stat_attribute_id), stage, operator: 'add' as const, weight: Number(profile.weight), softCap: profile.soft_cap, hardCap: profile.hard_cap, curveType: profile.curve_type, curveParameter: profile.curve_parameter }] : []; }); } export function evaluateLoadoutCombat( draft: LoadoutDraft, catalog: Catalog, summary = summarizeLoadout(draft, catalog) ): WeaponCalculationResult[] { const focus = selectedFocus(draft, catalog); const combatEffects = selectedNodeRanks(draft, catalog).flatMap(({ node, rank }: any) => (rank?.combatEffects ?? []).map((effect: any) => ({ ...effect, nodeName: node.name })) ); const required = (weaponIndex: number) => { const missing: string[] = []; if (!catalog.calculation.revision) missing.push('An active ISAC calculation revision'); else if (!catalog.calculation.profiles.length) missing.push('Published attribute calculation profiles'); if (!draft.weapons[weaponIndex].itemId) missing.push(`${weaponIndex ? 'Secondary' : 'Primary'} weapon`); for (const slot of gearSlots) if (!draft.gear[slot].itemId) missing.push(`${slot} gear`); if (!draft.protocol.itemId) missing.push('OS Protocol'); if (!draft.specialization.focusId) missing.push('Specialization focus'); else if ((focus?.abilities.length ?? 0) !== 3) missing.push('Three configured focus abilities'); const missingChips = draft.chips.filter((chip) => !chip.setId).length; if (missingChips) missing.push(`${missingChips} specialization Skill Chip${missingChips === 1 ? '' : 's'}`); return missing; }; return [0, 1].map((weaponIndex) => { const selection = draft.weapons[weaponIndex]; const weapon = catalog.weapons.find((item) => item.id === selection.itemId); const missing = required(weaponIndex); if (missing.length) { return { weaponName: weapon?.name ?? `${weaponIndex ? 'Secondary' : 'Primary'} weapon`, weaponLabel: weaponIndex ? 'SECONDARY WEAPON' : 'PRIMARY WEAPON', status: 'insufficient', missing, revisionLabel: catalog.calculation.revision ? `RULES R${catalog.calculation.revision.version}` : undefined }; } const bindings = calculationBindings(catalog, weapon); const effects = combatEffects.map((effect: any) => ({ id: String(effect.id), trigger: { hitsRequired: Number(effect.trigger_count), windowSeconds: Number(effect.trigger_window_seconds) }, lifecycle: { maxStacks: Number(effect.max_stacks), durationSeconds: Number(effect.duration_seconds), stacksPerTrigger: Number(effect.stacks_per_trigger) }, attributes: Object.fromEntries(effect.modifiers.map((modifier: any) => [ String(modifier.stat_attribute_id), Number(modifier.value_per_stack) ])) })); const perEffect = Object.fromEntries(combatEffects.map((effect: any) => { const state = draft.specialization.effectStates[effect.id] ?? { mode: 'off' }; return [effect.id, { mode: state.mode, ...(state.overrides ?? {}) }]; })); const result = calculateCombat({ weapon: { baseDamage: selection.damage, rpm: Number(weapon?.rpm), magazine: Number(weapon?.magazine), reloadSeconds: Number(weapon?.reload_speed), headshotMultiplier: 2 }, attributes: Object.fromEntries(summary.stats.map((stat) => [stat.id, stat.value])), bindings, stageOrder: catalog.calculation.stages .map((stage) => calculationStage(catalog, String(stage.stage_key))) .filter((stage): stage is CalculationStage => stage !== null), stackingEffects: effects, assumptions: { mode: 'off', ...draft.specialization.combatProfile, perEffect } }); if (result.status === 'INSUFFICIENT_DATA') { return { weaponName: weapon?.name ?? `${weaponIndex ? 'Secondary' : 'Primary'} weapon`, weaponLabel: weaponIndex ? 'SECONDARY WEAPON' : 'PRIMARY WEAPON', status: 'insufficient', missing: result.missing.map((item) => item.startsWith('binding:') ? `Calculation profile for ${attribute(catalog, item.slice(8))?.name ?? item.slice(8)}` : item.replaceAll('.', ' ') ), revisionLabel: `RULES R${catalog.calculation.revision.version}` }; } return { weaponName: weapon.name, weaponLabel: weaponIndex ? 'SECONDARY WEAPON' : 'PRIMARY WEAPON', status: 'complete', bodyHit: result.hitValues?.normal, criticalBodyHit: result.hitValues?.critical, headshot: result.hitValues?.headshot, criticalHeadshot: result.hitValues?.criticalHeadshot, burstDps: result.burstDps ?? undefined, sustainedDps: result.sustainedDps ?? undefined, encounterDps: result.encounterDps ?? undefined, effectSummaries: result.stackSummaries.map((stack) => { const effect = combatEffects.find((item: any) => String(item.id) === stack.effectId); return { id: stack.effectId, name: String(effect?.definition?.name ?? effect?.nodeName ?? 'Conditional effect'), mode: stack.mode, averageStacks: stack.averageStacks, maxStacksReached: stack.maxStacksReached, maxStacks: Number(effect?.max_stacks ?? 0), stacksAtEnd: stack.stacksAtEnd, buildUpSeconds: stack.buildUpSeconds, maintained: stack.maintained }; }), revisionLabel: `RULES R${catalog.calculation.revision.version}` }; }); }