export type Roll = { statId: string; value: number }; export const skillChipQualities = [ 'Standard', 'Specialized', 'Superior', 'High-end', ] as const; export type SkillChipQuality = (typeof skillChipQualities)[number]; export const skillChipSecondaryCapacity: Record = { Standard: 0, Specialized: 1, Superior: 2, 'High-end': 2, }; export type GearSelection = { itemId: string; level: number; brandId: string; talentId: string; health: number; armor: number; firepower: number; toughness: number; engineering: number; rolls: [Roll, Roll]; augmentSlots: number[]; }; export type WeaponSelection = { itemId: string; level: number; tier: number; damage: number; firepower: number; toughness: number; engineering: number; talent1Id: string; talent2Id: string; rolls: [Roll, Roll]; }; export type ChipSelection = { setId: string; quality: SkillChipQuality; primary: Roll; secondary: Roll[]; }; export type ProtocolSelection = { itemId: string; level: number; talentUpgrades: number; }; export type CombatProfile = { encounterSeconds: number; hitRate: number; headshotRate: number; shotsPerBurst: number; burstPauseSeconds: number; sameTargetSeconds: number; distinctTargetsPerMinute: number; killsPerMinute: number; headshotKillRate: number; damageTakenPerMinute: number; swapsPerMinute: number; targetDistanceMeters: number; healthPercent: number; extraHealthActive: boolean; }; export type CalculationMode = 'maximum' | 'custom'; export type EffectMode = 'off' | 'peak' | 'encounter'; export type ConditionalEffectSelection = { mode: EffectMode; overrides?: Partial; inputs?: Record; }; export type SkillActivationSelection = { mode: 'off' | 'on_cooldown' | 'custom_per_minute'; usesPerMinute?: number; targets: number; }; export type SpecializationSelection = { focusId: string; nodeRanks: Record; }; export type LoadoutDraft = { version: 5; buildName: string; calculationMode: CalculationMode; gear: Record; weapons: [WeaponSelection, WeaponSelection]; chips: [ ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ]; protocol: ProtocolSelection; conditionalEffects: Record; combatProfile: CombatProfile; skillActivations: Record; specialization: SpecializationSelection; }; export const gearSlots = [ 'Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask', ] as const; export const blankRoll = (): Roll => ({ statId: '', value: 0 }); export const blankGear = (): GearSelection => ({ itemId: '', level: 50, brandId: '', talentId: '', health: 0, armor: 0, firepower: 0, toughness: 0, engineering: 0, rolls: [blankRoll(), blankRoll()], augmentSlots: [], }); export const blankWeapon = (): WeaponSelection => ({ itemId: '', level: 50, tier: 0, damage: 0, firepower: 0, toughness: 0, engineering: 0, talent1Id: '', talent2Id: '', rolls: [blankRoll(), blankRoll()], }); export const blankChip = (): ChipSelection => ({ setId: '', quality: 'Standard', primary: blankRoll(), secondary: [], }); export const defaultCombatProfile = (): CombatProfile => ({ encounterSeconds: 60, hitRate: 75, headshotRate: 20, shotsPerBurst: 30, burstPauseSeconds: 0, sameTargetSeconds: 5, distinctTargetsPerMinute: 12, killsPerMinute: 4, headshotKillRate: 20, damageTakenPerMinute: 6, swapsPerMinute: 2, targetDistanceMeters: 15, healthPercent: 100, extraHealthActive: false, }); export const createDraft = (): LoadoutDraft => ({ version: 5, buildName: 'Untitled loadout', calculationMode: 'maximum', gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])), weapons: [blankWeapon(), blankWeapon()], chips: [ blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), ], protocol: { itemId: '', level: 1, talentUpgrades: 0 }, conditionalEffects: {}, combatProfile: defaultCombatProfile(), skillActivations: {}, specialization: { focusId: '', nodeRanks: {}, }, }); const isRecord = (value: unknown): value is Record => Boolean(value) && typeof value === 'object' && !Array.isArray(value); const hasExactKeys = (value: Record, keys: string[]) => Object.keys(value).length === keys.length && keys.every((key) => key in value); const isFiniteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); const isNumberRecord = (value: unknown) => isRecord(value) && Object.values(value).every( (entry) => typeof entry === 'number' && Number.isFinite(entry), ); const isRoll = (value: unknown): value is Roll => isRecord(value) && hasExactKeys(value, ['statId', 'value']) && typeof value.statId === 'string' && isFiniteNumber(value.value); const isRollPair = (value: unknown): value is [Roll, Roll] => Array.isArray(value) && value.length === 2 && value.every(isRoll); const isGearSelection = (value: unknown): value is GearSelection => isRecord(value) && hasExactKeys(value, [ 'itemId', 'level', 'brandId', 'talentId', 'health', 'armor', 'firepower', 'toughness', 'engineering', 'rolls', 'augmentSlots', ]) && ['itemId', 'brandId', 'talentId'].every( (key) => typeof value[key] === 'string', ) && ['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every( (key) => isFiniteNumber(value[key]), ) && isRollPair(value.rolls) && Array.isArray(value.augmentSlots) && value.augmentSlots.every(isFiniteNumber); const isWeaponSelection = (value: unknown): value is WeaponSelection => isRecord(value) && hasExactKeys(value, [ 'itemId', 'level', 'tier', 'damage', 'firepower', 'toughness', 'engineering', 'talent1Id', 'talent2Id', 'rolls', ]) && ['itemId', 'talent1Id', 'talent2Id'].every( (key) => typeof value[key] === 'string', ) && ['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every( (key) => isFiniteNumber(value[key]), ) && isRollPair(value.rolls); const isChipSelection = (value: unknown): value is ChipSelection => isRecord(value) && hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) && typeof value.setId === 'string' && skillChipQualities.some((quality) => value.quality === quality) && isRoll(value.primary) && Array.isArray(value.secondary) && value.secondary.length === skillChipSecondaryCapacity[value.quality as SkillChipQuality] && value.secondary.every(isRoll); const isProtocolSelection = (value: unknown): value is ProtocolSelection => isRecord(value) && hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) && typeof value.itemId === 'string' && isFiniteNumber(value.level) && isFiniteNumber(value.talentUpgrades); const isCombatProfile = (value: unknown): value is CombatProfile => isRecord(value) && hasExactKeys(value, [ 'encounterSeconds', 'hitRate', 'headshotRate', 'shotsPerBurst', 'burstPauseSeconds', 'sameTargetSeconds', 'distinctTargetsPerMinute', 'killsPerMinute', 'headshotKillRate', 'damageTakenPerMinute', 'swapsPerMinute', 'targetDistanceMeters', 'healthPercent', 'extraHealthActive', ]) && [ 'encounterSeconds', 'hitRate', 'headshotRate', 'shotsPerBurst', 'burstPauseSeconds', 'sameTargetSeconds', 'distinctTargetsPerMinute', 'killsPerMinute', 'headshotKillRate', 'damageTakenPerMinute', 'swapsPerMinute', 'targetDistanceMeters', 'healthPercent', ].every((key) => isFiniteNumber(value[key])) && typeof value.extraHealthActive === 'boolean'; const isEffectStates = ( value: unknown, ): value is Record => isRecord(value) && Object.values(value).every((entry) => { if ( !isRecord(entry) || !['off', 'peak', 'encounter'].includes(String(entry.mode)) ) return false; const allowed = new Set(['mode', 'overrides', 'inputs']); if ( Object.keys(entry).some((key) => !allowed.has(key)) || !['off', 'peak', 'encounter'].includes(String(entry.mode)) ) return false; if ( entry.inputs !== undefined && (!isRecord(entry.inputs) || Object.values(entry.inputs).some( (input) => typeof input !== 'boolean' && !isFiniteNumber(input), )) ) return false; return ( entry.overrides === undefined || (isRecord(entry.overrides) && Object.entries(entry.overrides).every(([key, override]) => key === 'extraHealthActive' ? typeof override === 'boolean' : key in defaultCombatProfile() && isFiniteNumber(override), )) ); }); const isSkillActivations = ( value: unknown, ): value is Record => isRecord(value) && Object.values(value).every( (entry) => isRecord(entry) && ['off', 'on_cooldown', 'custom_per_minute'].includes( String(entry.mode), ) && isFiniteNumber(entry.targets) && (entry.usesPerMinute === undefined || isFiniteNumber(entry.usesPerMinute)) && Object.keys(entry).every((key) => ['mode', 'usesPerMinute', 'targets'].includes(key), ), ); export function parseLoadoutDraft(value: unknown): LoadoutDraft | null { if ( !isRecord(value) || !hasExactKeys(value, [ 'version', 'buildName', 'calculationMode', 'gear', 'weapons', 'chips', 'protocol', 'conditionalEffects', 'combatProfile', 'skillActivations', 'specialization', ]) || value.version !== 5 || typeof value.buildName !== 'string' || !['maximum', 'custom'].includes(String(value.calculationMode)) ) return null; const gear = value.gear; if ( !isRecord(gear) || Object.keys(gear).length !== gearSlots.length || !gearSlots.every((slot) => isGearSelection(gear[slot])) ) return null; if ( !Array.isArray(value.weapons) || value.weapons.length !== 2 || !value.weapons.every(isWeaponSelection) ) return null; if ( !Array.isArray(value.chips) || value.chips.length !== 9 || !value.chips.every(isChipSelection) ) return null; if ( !isProtocolSelection(value.protocol) || !isEffectStates(value.conditionalEffects) || !isCombatProfile(value.combatProfile) || !isSkillActivations(value.skillActivations) ) return null; if ( !isRecord(value.specialization) || !hasExactKeys(value.specialization, ['focusId', 'nodeRanks']) || typeof value.specialization.focusId !== 'string' || !isNumberRecord(value.specialization.nodeRanks) ) return null; return structuredClone(value) as LoadoutDraft; } export const clamp = (value: unknown, min: unknown, max: unknown) => { const low = Number(min), high = Number(max), numeric = Number(value); if (!Number.isFinite(numeric)) return Number.isFinite(low) ? low : 0; return Math.min( Number.isFinite(high) ? high : numeric, Math.max(Number.isFinite(low) ? low : numeric, numeric), ); }; export const scaled = ( min: unknown, max: unknown, progress: number, steps: number, ) => Number(min) + ((Number(max) - Number(min)) * Math.min(steps, Math.max(0, progress))) / steps; export type LevelScaledRange = { rawMin: number; rawMax: number; min: number; max: number; provisional: boolean; }; const itemLevel = (value: unknown) => Math.round(clamp(value, 1, 50)); export const gearMultiplier = (level: unknown) => 0.91 + 0.05 * itemLevel(level); export const weaponMultiplier = (level: unknown) => 1.14 + 0.05 * itemLevel(level); export function gearBaseStatRange( minimum: unknown, maximum: unknown, targetLevel: unknown, ): LevelScaledRange { const target = itemLevel(targetLevel); const configuredMin = Number(minimum); const configuredMax = Number(maximum); const rawMin = (Number.isFinite(configuredMin) ? configuredMin : 0) * gearMultiplier(target); const rawMax = (Number.isFinite(configuredMax) ? configuredMax : 0) * gearMultiplier(target); return { rawMin, rawMax, min: Math.floor(rawMin), max: Math.floor(rawMax), provisional: target < 10, }; } export function weaponBaseRange( minimum: unknown, maximum: unknown, targetLevel: unknown, ): LevelScaledRange { const target = itemLevel(targetLevel); const configuredMin = Number(minimum); const configuredMax = Number(maximum); const rawMin = (Number.isFinite(configuredMin) ? configuredMin : 0) * weaponMultiplier(target); const rawMax = (Number.isFinite(configuredMax) ? configuredMax : 0) * weaponMultiplier(target); return { rawMin, rawMax, min: Math.floor(rawMin), max: Math.floor(rawMax), provisional: target < 40, }; } export function levelScaledRange( minimum: unknown, maximum: unknown, targetLevel: unknown, referenceLevel: unknown, source: 'gear' | 'weapon', ): LevelScaledRange { const target = itemLevel(targetLevel); const reference = itemLevel(referenceLevel); const multiplier = source === 'gear' ? gearMultiplier : weaponMultiplier; const ratio = multiplier(target) / multiplier(reference); const configuredMin = Number(minimum); const configuredMax = Number(maximum); const rawMin = (Number.isFinite(configuredMin) ? configuredMin : 0) * ratio; const rawMax = (Number.isFinite(configuredMax) ? configuredMax : 0) * ratio; return { rawMin, rawMax, min: Math.floor(rawMin), max: Math.floor(rawMax), provisional: source === 'gear' ? target < 10 || reference < 10 : target < 40 || reference < 40, }; } export function remapRangeValue( value: unknown, from: LevelScaledRange, to: LevelScaledRange, ) { const span = from.max - from.min; const position = span > 0 ? (clamp(value, from.min, from.max) - from.min) / span : 0; return Math.round(to.min + (to.max - to.min) * position); }