import { calculateCombat, expectedWeaponShotDamage, resolveWeaponFiringCycle, type AttributeBinding, type CalculationStage } from '$lib/calculations/combat'; import { evaluateMechanicProgram, type MechanicCombatContext, type MechanicEvaluation, type MechanicEvent, type MechanicOutcome, type MechanicProgram, type NumericExpression } from '$lib/calculations/mechanic'; import { adaptConditionalEffectProgram } from '$lib/calculations/mechanic-persistence'; import { calculateSkillDps, type SkillAttributeBinding, type SkillCalculation, type SkillModifierWindow } from '$lib/calculations/skill'; 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[]; skillProfiles?: 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 applyAttributeValue(catalog: Catalog, current: number, statId: string, incoming: number) { const stat = attribute(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: 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, rule: catalog.talents.find((entry) => entry.id === selection.talentId)?.rule ?? null, 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, rule: catalog.talents.find((entry) => entry.id === talentId)?.rule ?? null, 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, 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) => (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', 'rate_of_fire', 'magazine_size', 'reload_speed', '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'; if (stage.operator === 'rate_of_fire') return 'rate_of_fire'; if (stage.operator === 'magazine_size') return 'magazine_size'; if (stage.operator === 'reload_speed') return 'reload_speed'; 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 }] : []; }); } function transformedBindingValue(raw: number, binding: AttributeBinding) { const weight = Number.isFinite(binding.weight) ? Number(binding.weight) : 1; const sign = raw < 0 ? -1 : 1; let value = Math.abs(raw * weight); const softCap = Number.isFinite(Number(binding.softCap)) && Number(binding.softCap) >= 0 ? Number(binding.softCap) : null; const hardCap = Number.isFinite(Number(binding.hardCap)) && Number(binding.hardCap) >= 0 ? Number(binding.hardCap) : null; if (softCap !== null && value > softCap && binding.curveType && binding.curveType !== 'none') { const parameter = Math.max(0.01, Number(binding.curveParameter ?? 1)); const overflow = value - softCap; if (binding.curveType === 'power') { value = softCap + overflow * Math.pow(softCap / Math.max(softCap + overflow, 0.000_001), parameter); } else { const ceiling = hardCap ?? Math.max(softCap * 2, softCap + 1); const ratio = Math.min(1, Math.max(0, overflow / Math.max(ceiling - softCap, 0.000_001))); value = softCap + (ceiling - softCap) * (ratio * ratio * (3 - 2 * ratio)); } } if (hardCap !== null) value = Math.min(value, hardCap); return value * sign; } export function weaponCriticalChance( attributes: Record, bindings: AttributeBinding[] ) { return Math.min(100, Math.max(0, bindings .filter((binding) => binding.stage === 'critical_chance' && binding.operator === 'add') .reduce((total, binding) => total + transformedBindingValue(Number(attributes[binding.attributeId] ?? 0), binding), 0))); } function conditionalProgram( rule: any, variantKey: string, valueScale = 1 ): MechanicProgram | null { if (!rule?.rule) return null; const adapted = adaptConditionalEffectProgram(rule, { variantKey }); if (!adapted.program || adapted.status === 'INVALID' || adapted.status === 'CAPTURED_ONLY') return null; if (valueScale === 1) return adapted.program; const scale = (value: NumericExpression): NumericExpression => typeof value === 'number' ? value * valueScale : value; return { ...adapted.program, clauses: adapted.program.clauses.map((clause) => ({ ...clause, outcomes: clause.outcomes?.map((outcome) => ({ ...outcome, value: scale(outcome.value) })) })) }; } function interpolatedConditionalProgram( rule: any, minimumVariant: string, maximumVariant: string, step: number, totalSteps: number ): MechanicProgram | null { const minimum = conditionalProgram(rule, minimumVariant); const maximum = conditionalProgram(rule, maximumVariant); if (!minimum || !maximum) return minimum ?? maximum; const ratio = Math.max(0, Math.min(1, Number(step) / Math.max(1, Number(totalSteps)))); const maximumByKey = new Map(maximum.clauses.flatMap((clause) => (clause.outcomes ?? []).map((outcome) => [`${clause.id}:${outcome.id}`, outcome] as const) )); return { ...minimum, clauses: minimum.clauses.map((clause) => ({ ...clause, outcomes: clause.outcomes?.map((outcome) => { const upper = maximumByKey.get(`${clause.id}:${outcome.id}`); return upper && typeof outcome.value === 'number' && typeof upper.value === 'number' ? { ...outcome, value: outcome.value + (upper.value - outcome.value) * ratio } : outcome; }) })) }; } function equippedConditionalProgram(equipped: any): MechanicProgram | null { if (String(equipped.key).startsWith('chips:')) { return interpolatedConditionalProgram(equipped.rule, 'minimum', 'maximum', Number(equipped.tier), 3); } if (String(equipped.key).startsWith('protocol:')) { return conditionalProgram(equipped.rule, 'base'); } return conditionalProgram( equipped.rule, String(equipped.key).startsWith('weapon:') || String(equipped.key).startsWith('gear:') ? (Number(equipped.tier) >= 2 ? 'II' : 'I') : 'base' ); } type ActiveMechanicEvaluation = { status: 'OK'; result: MechanicEvaluation; program: MechanicProgram; encounterSeconds: number; }; function mechanicScenarioInputs(program: MechanicProgram, configured: unknown) { const values = configured && typeof configured === 'object' && !Array.isArray(configured) ? configured as Record : {}; return Object.fromEntries((program.scenarioInputs ?? []).map((input) => [ input.key, values[input.key] ?? input.defaultValue ?? (input.kind === 'toggle' ? false : 0) ])); } function evaluateConfiguredMechanic( program: MechanicProgram, events: MechanicEvent[], state: any, profile: LoadoutDraft['combatProfile'], weaponBaseDamage: number, maxHealth: number, calculationMode: LoadoutDraft['calculationMode'], equipmentContext = { protocolTalentUpgrades: 0 } ): ActiveMechanicEvaluation | null { if (calculationMode !== 'maximum' && state.mode === 'off') return null; const encounterSeconds = Math.max(1, Number(profile.encounterSeconds)); const maximum = calculationMode === 'maximum' || state.mode === 'peak'; const result = evaluateMechanicProgram( program, events, maximum ? { mode: 'maximum', encounterSeconds } : { mode: 'custom', encounterSeconds, inputs: mechanicScenarioInputs(program, state.inputs) }, { maxHealth: Math.max(1, maxHealth), currentHealth: Math.max(0, maxHealth * Number(profile.healthPercent) / 100), weaponBaseDamage, values: { extraHealth: profile.extraHealthActive ? 1 : 0 } }, equipmentContext ); return result.status === 'OK' ? { status: 'OK', result, program, encounterSeconds } : null; } type ModifierInterval = { start: number; end: number; value: number; stateValue: number; attributeId: string; scope: string; outcomeId: string; phase: MechanicOutcome['phase']; activationEvent: MechanicEvent['kind']; endAtNextActivation: boolean; }; function modifierIntervals(source: ActiveMechanicEvaluation): ModifierInterval[] { const emissions = source.result.outcomes .filter((emission) => emission.kind === 'attribute_modifier' && emission.attributeId) .sort((left, right) => left.at - right.at); return emissions.flatMap((emission, index) => { const clause = source.program.clauses.find((candidate) => candidate.outcomes?.some((outcome) => outcome.id === emission.outcomeId)); const definition = clause?.outcomes?.find((candidate) => candidate.id === emission.outcomeId); if (!clause || !definition) return []; const next = emissions.slice(index + 1).find((candidate) => candidate.outcomeId === emission.outcomeId); const pool = definition.statePool ? source.program.pools?.find((candidate) => candidate.key === definition.statePool) : null; const naturalEnd = definition.phase === 'while_active' ? emission.at + Number(pool?.expiresAfterSeconds ?? source.encounterSeconds) : emission.at; const end = Math.min(source.encounterSeconds, next?.at ?? naturalEnd, naturalEnd); if ( end <= emission.at && definition.phase !== 'while_active' && definition.phase !== 'on_event' ) return []; return [{ start: emission.at, end: Math.max(emission.at, end), value: emission.appliedValue, stateValue: emission.stateValue, attributeId: String(emission.attributeId), scope: emission.scope, outcomeId: emission.outcomeId, phase: definition.phase, activationEvent: clause.when, endAtNextActivation: next !== undefined && next.at <= naturalEnd }]; }); } function averageModifierValues(source: ActiveMechanicEvaluation) { const values = new Map(); for (const interval of modifierIntervals(source)) { const key = `${interval.attributeId}:${interval.scope}`; const current = values.get(key) ?? { value: 0, scope: interval.scope }; current.value += interval.value * (interval.end - interval.start) / source.encounterSeconds; values.set(key, current); } return [...values].map(([key, value]) => ({ attributeId: key.slice(0, key.lastIndexOf(':')), scope: value.scope, value: value.value })); } const TIMELINE_EVENT_ORDER: Readonly> = { firing_started: 0, shot_fired: 1, weapon_hit: 2, damage_dealt: 3, firing_stopped: 4, reload_started: 5, reload_completed: 6, weapon_swapped: 7, enemy_killed: 8, assist: 9, skill_deployed: 10, skill_hit: 11, agent_hit: 12, health_damage_taken: 13, healing_applied: 14, extra_health_granted: 15, tick: 16, proximity_snapshot: 17, movement_snapshot: 18 }; function intervalAppliesAt( interval: ModifierInterval, at: number, eventKind: MechanicEvent['kind'] ) { const epsilon = 0.000_001; const queryOrder = TIMELINE_EVENT_ORDER[eventKind]; const activationOrder = TIMELINE_EVENT_ORDER[interval.activationEvent]; if (interval.phase === 'on_event') { return Math.abs(at - interval.start) <= epsilon && activationOrder <= queryOrder; } if (interval.phase !== 'while_active') return false; if (at < interval.start - epsilon || at > interval.end + epsilon) return false; if (Math.abs(at - interval.start) <= epsilon && activationOrder >= queryOrder) return false; if ( Math.abs(at - interval.end) <= epsilon && !interval.endAtNextActivation ) { return false; } return true; } export function mechanicModifiersAt( source: ActiveMechanicEvaluation, at: number, eventKind: MechanicEvent['kind'] = 'weapon_hit' ) { const values = new Map(); for (const interval of modifierIntervals(source)) { if (!intervalAppliesAt(interval, at, eventKind)) continue; const key = `${interval.attributeId}:${interval.scope}`; const current = values.get(key) ?? { value: 0, scope: interval.scope }; current.value += interval.value; values.set(key, current); } return [...values].map(([key, value]) => ({ attributeId: key.slice(0, key.lastIndexOf(':')), scope: value.scope, value: value.value })); } function mechanicSummary(source: ActiveMechanicEvaluation) { const intervals = modifierIntervals(source); const maxStacks = Math.max(1, ...(source.program.pools ?? []).map((pool) => Number(pool.max ?? 1))); const maxStacksReached = Math.max(0, ...intervals.map((interval) => interval.stateValue)); const covered = intervals.reduce((total, interval) => total + Math.max(0, interval.end - interval.start), 0); const uptimePercent = Math.min(100, covered / source.encounterSeconds * 100); const buildUp = source.result.outcomes.find((emission) => emission.stateValue >= maxStacks)?.at ?? null; return { averageStacks: intervals.reduce( (total, interval) => total + interval.stateValue * (interval.end - interval.start) / source.encounterSeconds, 0 ), maxStacksReached, maxStacks, stacksAtEnd: Math.max(0, ...source.result.finalState.map((state) => state.value)), buildUpSeconds: buildUp, maintained: uptimePercent >= 99.9 && maxStacksReached >= maxStacks, uptimePercent, activationCount: source.result.estimatedActivationCount, instantTotal: source.result.outcomes .filter((outcome) => outcome.kind !== 'direct_damage' && outcome.kind !== 'attribute_modifier') .reduce((total, outcome) => total + outcome.appliedValue, 0) }; } export function buildWeaponMechanicTimeline( weapon: any, profile: LoadoutDraft['combatProfile'], criticalChancePercent = 0 ): MechanicEvent[] { const encounterSeconds = Math.max(0, Number(profile.encounterSeconds)); const rpm = Math.max(1, Number(weapon?.rpm)); const magazine = Math.max(1, Math.round(Number(weapon?.magazine))); const reloadSeconds = Math.max(0, Number(weapon?.reload_speed)); const shotsPerBurst = Math.max(1, Math.round(Number(profile.shotsPerBurst))); const pauseSeconds = Math.max(0, Number(profile.burstPauseSeconds)); const targetInterval = Number(profile.distinctTargetsPerMinute) > 0 ? Math.min( Math.max(0.1, Number(profile.sameTargetSeconds)), 60 / Number(profile.distinctTargetsPerMinute) ) : Math.max(0.1, Number(profile.sameTargetSeconds)); const events: MechanicEvent[] = [{ kind: 'firing_started', at: 0 }]; let time = 0; let magazineRemaining = magazine; let burstRemaining = shotsPerBurst; let hitAccumulator = 0; let headshotAccumulator = 0; let criticalAccumulator = 0; const criticalChance = Math.max(0, Math.min(100, Number(criticalChancePercent))) / 100; const shotInterval = 60 / rpm; while (time <= encounterSeconds && events.length < 100_000) { if (magazineRemaining <= 0) { events.push({ kind: 'reload_started', at: time }); time += reloadSeconds; if (time <= encounterSeconds) { events.push({ kind: 'reload_completed', at: time }); events.push({ kind: 'firing_started', at: time }); } magazineRemaining = magazine; burstRemaining = shotsPerBurst; continue; } if (burstRemaining <= 0) { if (pauseSeconds > 0) { events.push({ kind: 'firing_stopped', at: time }); time += pauseSeconds; if (time <= encounterSeconds) events.push({ kind: 'firing_started', at: time }); } burstRemaining = shotsPerBurst; continue; } hitAccumulator += Math.max(0, Math.min(100, Number(profile.hitRate))) / 100; const hit = hitAccumulator >= 1; if (hit) hitAccumulator -= 1; if (hit) headshotAccumulator += Math.max(0, Math.min(100, Number(profile.headshotRate))) / 100; const headshot = hit && headshotAccumulator >= 1; if (headshot) headshotAccumulator -= 1; if (hit) criticalAccumulator += criticalChance; const critical = hit && criticalAccumulator >= 1; if (critical) criticalAccumulator -= 1; const targetId = `target-${Math.floor(time / targetInterval)}`; events.push({ kind: 'shot_fired', at: time, targetId, values: { magazineRemaining, magazinePercent: magazineRemaining / magazine * 100, distanceMeters: Number(profile.targetDistanceMeters) } }); if (hit) { const values = { headshot, critical, distanceMeters: Number(profile.targetDistanceMeters) }; events.push({ kind: 'weapon_hit', at: time, targetId, values }); events.push({ kind: 'damage_dealt', at: time, targetId, values: { ...values, source: 'weapon' } }); } magazineRemaining -= 1; burstRemaining -= 1; time += shotInterval; } events.push({ kind: 'firing_stopped', at: encounterSeconds }); const intervalEvents = ( kind: 'enemy_killed' | 'health_damage_taken' | 'weapon_swapped', perMinute: number ) => { if (!(perMinute > 0)) return; const interval = 60 / perMinute; let headshotKillAccumulator = 0; for (let at = interval; at <= encounterSeconds; at += interval) { if (kind === 'enemy_killed') { headshotKillAccumulator += Math.max(0, Math.min(100, Number(profile.headshotKillRate))) / 100; const headshot = headshotKillAccumulator >= 1; if (headshot) headshotKillAccumulator -= 1; events.push({ kind, at, targetId: `target-${Math.floor(at / targetInterval)}`, values: { headshot, source: 'weapon' } }); } else events.push({ kind, at }); } }; intervalEvents('enemy_killed', Number(profile.killsPerMinute)); intervalEvents('health_damage_taken', Number(profile.damageTakenPerMinute)); intervalEvents('weapon_swapped', Number(profile.swapsPerMinute)); for (let at = 0; at <= encounterSeconds; at += 1) { events.push({ kind: 'tick', at }); events.push({ kind: 'proximity_snapshot', at, values: { distanceMeters: Number(profile.targetDistanceMeters), targetDistanceMeters: Number(profile.targetDistanceMeters), enemyCount: 1, allyCount: 0 } }); events.push({ kind: 'movement_snapshot', at, values: { moving: false, sprinting: false } }); } return events.sort((left, right) => left.at - right.at); } function effectAppliesToWeapon(scope: string, sourceKey: string, weaponIndex: number) { if (scope === 'allies' || scope === 'assigned_ability' || scope === 'all_skills') return false; if (scope !== 'equipped_weapon') return true; const match = sourceKey.match(/^weapon:(\d+):/); return !match || Number(match[1]) === weaponIndex; } function effectAppliesToSkills(scope: string) { return !['allies', 'equipped_weapon'].includes(scope); } function skillModifierWindows( draft: LoadoutDraft, catalog: Catalog, summary: LoadoutSummary, allowedAttributeIds: Set ): SkillModifierWindow[] { const focus = selectedFocus(draft, catalog); const combatEffects = selectedNodeRanks(draft, catalog).flatMap(({ node, rank }: any) => (rank?.combatEffects ?? []).map((effect: any) => ({ ...effect, nodeName: node.name, abilityId: null })) ); const sources = [ ...summary.equippedTalents.map((equipped: any) => ({ key: String(equipped.key), abilityId: equipped.abilityId ? String(equipped.abilityId) : null, equipmentContext: { protocolTalentUpgrades: String(equipped.key).startsWith('protocol:') ? Number(equipped.tier) : 0 }, program: equippedConditionalProgram(equipped) })), ...combatEffects.map((effect: any) => ({ key: String(effect.id), abilityId: effect.abilityId, equipmentContext: { protocolTalentUpgrades: 0 }, program: conditionalProgram(effect.rule, 'base') })) ].filter((source) => source.program); const primarySelection = draft.weapons[0]; const primaryWeapon = catalog.weapons.find((weapon) => weapon.id === primarySelection.itemId); const primaryBindings = calculationBindings(catalog, primaryWeapon); const baseAttributes = Object.fromEntries(summary.stats.map((stat) => [stat.id, stat.value])); const primaryCriticalChance = weaponCriticalChance(baseAttributes, primaryBindings); const focusAbilityIds = new Set((focus?.abilities ?? []).map((ability: any) => String(ability.id))); const windows: SkillModifierWindow[] = []; const maxHealthId = catalog.attributes.find((item) => item.name === 'Health')?.id; const maxHealth = summary.stats.find((stat) => stat.id === maxHealthId)?.value ?? 1; const evaluatedSources = sources.flatMap((source) => { const state = draft.conditionalEffects[source.key] ?? { mode: 'off' }; const profile = { ...draft.combatProfile, ...(state.overrides ?? {}) }; const evaluation = evaluateConfiguredMechanic( source.program!, primaryWeapon ? buildWeaponMechanicTimeline(primaryWeapon, profile, primaryCriticalChance) : [], state, profile, Number(primarySelection.damage ?? 0), maxHealth, draft.calculationMode, source.equipmentContext ); return evaluation ? [{ ...source, evaluation }] : []; }); for (const source of evaluatedSources) { for (const interval of modifierIntervals(source.evaluation)) { if (!effectAppliesToSkills(interval.scope) || !allowedAttributeIds.has(interval.attributeId)) continue; const abilityIds = interval.scope === 'assigned_ability' ? (source.abilityId && focusAbilityIds.has(source.abilityId) ? [source.abilityId] : []) : undefined; if (interval.scope === 'assigned_ability' && !abilityIds?.length) continue; windows.push({ startSeconds: interval.start, endSeconds: interval.end, attributes: { [interval.attributeId]: interval.value }, abilityIds }); } } return windows; } 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 conditionalSources = [ ...summary.equippedTalents.map((equipped: any) => ({ key: equipped.key, name: equipped.custom?.name ?? catalog.talents.find((talent) => talent.id === equipped.id)?.name ?? 'Talent', equipmentContext: { protocolTalentUpgrades: String(equipped.key).startsWith('protocol:') ? Number(equipped.tier) : 0 }, program: equippedConditionalProgram(equipped) })), ...combatEffects.map((effect: any) => ({ key: String(effect.id), name: String(effect.definition?.name ?? effect.nodeName ?? 'Specialization effect'), equipmentContext: { protocolTalentUpgrades: 0 }, program: conditionalProgram(effect.rule, 'base') })) ].filter((source) => source.program); 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 baseAttributes = Object.fromEntries(summary.stats.map((stat) => [stat.id, stat.value])); const maxHealthId = catalog.attributes.find((item) => item.name === 'Health')?.id; const maxHealth = Number(baseAttributes[maxHealthId] ?? 1); const evaluateSources = ( timelineWeapon: any, criticalAttributes: Record ) => conditionalSources.flatMap((source) => { const state = draft.conditionalEffects[source.key] ?? { mode: 'off' }; const profile = { ...draft.combatProfile, ...(state.overrides ?? {}) }; const evaluation = evaluateConfiguredMechanic( source.program!, buildWeaponMechanicTimeline( timelineWeapon, profile, weaponCriticalChance(criticalAttributes, bindings) ), state, profile, Number(selection.damage), maxHealth, draft.calculationMode, source.equipmentContext ); return evaluation ? [{ ...source, state, evaluation }] : []; }); const attributesFrom = (evaluations: ReturnType) => { const values = { ...baseAttributes }; for (const source of evaluations) { for (const modifier of averageModifierValues(source.evaluation)) { if (!effectAppliesToWeapon(modifier.scope, source.key, weaponIndex)) continue; values[modifier.attributeId] = applyAttributeValue( catalog, values[modifier.attributeId] ?? 0, modifier.attributeId, modifier.value ); } } return values; }; let timelineWeapon = { ...weapon }; let evaluations = evaluateSources(timelineWeapon, baseAttributes); for (let pass = 0; pass < 3; pass += 1) { const currentAttributes = attributesFrom(evaluations); const cycle = resolveWeaponFiringCycle( { rpm: Number(weapon?.rpm), magazine: Number(weapon?.magazine), reloadSeconds: Number(weapon?.reload_speed) }, currentAttributes, bindings ); timelineWeapon = { ...weapon, rpm: cycle.rpm, magazine: cycle.magazine, reload_speed: cycle.reloadSeconds }; evaluations = evaluateSources(timelineWeapon, currentAttributes); } const modeledAttributes = attributesFrom(evaluations); let directDamageTotal = 0; for (const source of evaluations) { for (const outcome of source.evaluation.result.outcomes) { if (outcome.kind !== 'direct_damage' || !effectAppliesToWeapon(outcome.scope, source.key, weaponIndex)) continue; directDamageTotal += outcome.appliedValue; } } const calculationStages = catalog.calculation.stages .map((stage) => calculationStage(catalog, String(stage.stage_key))) .filter((stage): stage is CalculationStage => stage !== null); const result = calculateCombat({ weapon: { baseDamage: selection.damage, rpm: Number(weapon?.rpm), magazine: Number(weapon?.magazine), reloadSeconds: Number(weapon?.reload_speed), headshotMultiplier: 2 }, attributes: modeledAttributes, bindings, stageOrder: calculationStages, assumptions: { mode: 'off', hitRate: draft.combatProfile.hitRate, headshotRate: draft.combatProfile.headshotRate, encounterSeconds: draft.combatProfile.encounterSeconds, firingActivity: 100, } }); 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}` }; } const timeline = buildWeaponMechanicTimeline( timelineWeapon, draft.combatProfile, weaponCriticalChance(modeledAttributes, bindings) ); let encounterWeaponDamage = 0; for (const event of timeline) { if (event.kind !== 'weapon_hit') continue; const attributesAtShot = { ...baseAttributes }; for (const source of evaluations) { for (const modifier of mechanicModifiersAt(source.evaluation, event.at, event.kind)) { if (!effectAppliesToWeapon(modifier.scope, source.key, weaponIndex)) continue; attributesAtShot[modifier.attributeId] = applyAttributeValue( catalog, attributesAtShot[modifier.attributeId] ?? 0, modifier.attributeId, modifier.value ); } } encounterWeaponDamage += expectedWeaponShotDamage( { baseDamage: selection.damage, rpm: Number(weapon?.rpm), magazine: Number(weapon?.magazine), reloadSeconds: Number(weapon?.reload_speed), headshotMultiplier: 2 }, attributesAtShot, bindings, event.values?.headshot === true, calculationStages ); } 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: (encounterWeaponDamage + directDamageTotal) / Math.max(1, draft.combatProfile.encounterSeconds), effectSummaries: evaluations.map((source) => ({ id: source.key, name: source.name, mode: source.state.mode, ...mechanicSummary(source.evaluation) })), revisionLabel: `RULES R${catalog.calculation.revision.version}` }; }); } export function evaluateLoadoutSkillCombat( draft: LoadoutDraft, catalog: Catalog, summary = summarizeLoadout(draft, catalog) ): SkillCalculation { const focus = selectedFocus(draft, catalog); const revisionId = String(catalog.calculation.revision?.id ?? ''); const supportedStages = new Set([ 'flat_damage', 'skill_damage', 'final_damage', 'critical_chance', 'critical_damage', 'multishot_chance', 'multishot_damage', 'cooldown_recovery', 'duration' ]); const bindings: SkillAttributeBinding[] = (catalog.calculation.skillProfiles ?? []) .flatMap((profile: any) => { const stage = profile.stage_key === 'engineering' ? 'skill_damage' : String(profile.stage_key); return supportedStages.has(stage) ? [{ attributeId: String(profile.stat_attribute_id), stage: stage as SkillAttributeBinding['stage'], operator: profile.operator === 'multiply' ? 'multiply' as const : 'add' as const, weight: Number(profile.weight ?? 1) }] : []; }); const boundIds = new Set(bindings.map((binding) => binding.attributeId)); const attributes = Object.fromEntries( summary.stats.filter((stat) => boundIds.has(stat.id)).map((stat) => [stat.id, stat.value]) ); const abilities = (focus?.abilities ?? []).map((ability: any) => { const attack = ability.attack; const selected = draft.skillActivations[ability.id] ?? { mode: 'off', targets: 1 }; const activation = selected.mode === 'custom_per_minute' ? { mode: 'custom_per_minute' as const, usesPerMinute: Number(selected.usesPerMinute ?? 1) } : selected.mode === 'on_cooldown' ? { mode: 'on_cooldown' as const } : { mode: 'off' as const }; const chargePolicy = attack?.charge_policy === 'independent' ? { type: 'independent' as const, charges: Number(attack.charge_count) } : attack?.charge_policy === 'full_set' ? { type: 'full_set' as const, charges: Number(attack.charge_count) } : { type: 'none' as const }; return { id: String(ability.id), baseDamage: attack?.base_damage === undefined ? undefined : Number(attack.base_damage), damageType: attack?.damage_type, delivery: attack?.delivery === 'periodic' ? { type: 'periodic' as const, ticks: Math.max(1, Math.floor(Number(attack.damage_duration_seconds) / Number(attack.tick_interval_seconds))), durationSeconds: Number(attack.damage_duration_seconds), hitsPerTick: Number(attack.hits_per_activation ?? 1), intervalSeconds: Number(attack.tick_interval_seconds), firstTickDelaySeconds: Number(attack.tick_interval_seconds) } : { type: 'direct' as const, hits: Number(attack?.hits_per_activation ?? 1) }, castSeconds: Number(attack?.cast_seconds ?? 0), cooldownSeconds: Number(ability.cooldown_seconds ?? 0), targetCap: Number(attack?.target_cap ?? 1), targetCount: Number(selected.targets ?? 1), chargePolicy, activation }; }); return calculateSkillDps({ rules: { revisionId, bindings }, abilities, attributes, attributeRules: Object.fromEntries([...boundIds].map((id) => { const stat = attribute(catalog, id); return [id, { combination: stat?.calculation_type === 'Multiplicative' && stat?.unit === '%' ? 'multiplicative_percent' as const : 'additive' as const, cap: stat?.cap_value === null || stat?.cap_value === undefined ? null : Number(stat.cap_value) }]; })), modifierWindows: skillModifierWindows(draft, catalog, summary, boundIds), assumptions: { encounterSeconds: draft.combatProfile.encounterSeconds, targetCount: Math.max(1, ...Object.values(draft.skillActivations).map((selection) => Number(selection.targets ?? 1))) } }); }