const form = document.getElementById('af-calculator'); const weaponSelect = document.getElementById('af-weapon-select'); const outputs = { shotDamage: document.querySelector('[data-output="shotDamage"]'), burstDps: document.querySelector('[data-output="burstDps"]'), sustainedDps: document.querySelector('[data-output="sustainedDps"]'), timeToEmpty: document.querySelector('[data-output="timeToEmpty"]'), magDamage: document.querySelector('[data-output="magDamage"]'), ttk: document.querySelector('[data-output="ttk"]') }; const resetButton = document.getElementById('af-reset'); const copyButton = document.getElementById('af-copy'); const weaponsById = new Map(); const modifiersById = new Map(); const operativesById = new Map(); const operativeSelect = document.getElementById('af-operative-select'); const loadoutSlotSelect = document.getElementById('af-loadout-slot'); const modifierStack = document.querySelector('.af-modifier-stack'); const itemModifierList = document.getElementById('af-item-modifiers'); const perkModifierList = document.getElementById('af-perk-modifiers'); const abilityModifierList = document.getElementById('af-ability-modifiers'); const operativeModifierList = document.getElementById('af-operative-modifiers'); const operativeAbilityModifierList = document.getElementById('af-operative-ability-modifiers'); const operativeArmorModifierList = document.getElementById('af-operative-armor-modifiers'); const activeModifierList = document.getElementById('af-active-modifiers'); let allWeapons = []; let currentWeaponTypes = []; let currentOperativeCategory = null; const numberOrNull = (value) => { const parsed = Number.parseFloat(value); return Number.isFinite(parsed) ? parsed : null; }; const formatValue = (value, suffix = '') => { if (value === null) { return '--'; } const formatted = Number.isFinite(value) ? value.toFixed(2) : '--'; return `${formatted}${suffix}`; }; const matchesOperative = (modifierOperative) => { if (!modifierOperative) { return true; } if (!currentOperativeCategory) { return false; } return modifierOperative.toLowerCase() === currentOperativeCategory.toLowerCase(); }; const matchesWeaponType = (appliesTo) => { if (!appliesTo) { return true; } if (!currentWeaponTypes.length) { return false; } return currentWeaponTypes.map((type) => type.toUpperCase()).includes(appliesTo.toUpperCase()); }; const applyBuffValue = (current, value, buffType) => { if (!Number.isFinite(current)) { return current; } if (!Number.isFinite(value)) { return current; } if (buffType === 'additive') { return current + value; } return current * value; }; const applyModifierToStats = (modifier, stats) => { if (!modifier) { return stats; } if (!matchesOperative(modifier.operatives) || !matchesWeaponType(modifier.appliesTo)) { return stats; } if (Number.isFinite(modifier.value) && modifier.type) { if (modifier.type === 'additive') { stats.damage += modifier.value; } if (modifier.type === 'multiplicative') { stats.damage *= modifier.value; } } if (Array.isArray(modifier.buffs)) { modifier.buffs.forEach((buff) => { const buffType = buff.buffType?.startsWith('additive') ? 'additive' : 'multiplicative'; if (buff.type === 'damage') { stats.damage = applyBuffValue(stats.damage, buff.value, buffType); } if (buff.type === 'rpm') { stats.rpm = applyBuffValue(stats.rpm, buff.value, buffType); } if (buff.type === 'reloadTime') { stats.reload = applyBuffValue(stats.reload, buff.value, buffType); } }); } return stats; }; const getSelectedModifiers = () => { const checkboxes = document.querySelectorAll('[data-modifier-id]:checked'); return Array.from(checkboxes) .map((checkbox) => modifiersById.get(checkbox.dataset.modifierId)) .filter(Boolean); }; const updateActiveModifierList = (modifiers) => { if (!activeModifierList) { return; } activeModifierList.innerHTML = ''; if (!modifiers.length) { const tag = document.createElement('span'); tag.className = 'af-tag'; tag.textContent = 'No modifiers selected'; activeModifierList.appendChild(tag); return; } modifiers.forEach((modifier) => { const tag = document.createElement('span'); tag.className = 'af-tag'; tag.textContent = modifier.label ?? 'Unnamed modifier'; activeModifierList.appendChild(tag); }); }; const calculate = () => { const data = new FormData(form); const baseDamage = numberOrNull(data.get('baseDamage')); const projectiles = numberOrNull(data.get('projectiles')) ?? 1; const rpm = numberOrNull(data.get('rpm')); const magazine = numberOrNull(data.get('magazine')); const reload = numberOrNull(data.get('reload')); const targetHealth = numberOrNull(data.get('targetHealth')); if (!baseDamage || !rpm) { Object.values(outputs).forEach((node) => { node.textContent = '--'; }); updateActiveModifierList([]); return; } const modifiers = getSelectedModifiers(); const stats = modifiers.reduce( (acc, modifier) => applyModifierToStats(modifier, acc), { damage: baseDamage, rpm, reload } ); const shotDamage = stats.damage * projectiles; const shotsPerSecond = stats.rpm / 60; const burstDps = shotDamage * shotsPerSecond; const magDamage = magazine ? shotDamage * magazine : null; const timeToEmpty = magazine ? magazine / shotsPerSecond : null; let sustainedDps = null; if (magDamage !== null && stats.reload !== null && timeToEmpty !== null && (timeToEmpty + stats.reload) > 0) { sustainedDps = magDamage / (timeToEmpty + stats.reload); } let ttk = null; if (targetHealth && burstDps > 0) { ttk = targetHealth / burstDps; } outputs.shotDamage.textContent = formatValue(shotDamage); outputs.burstDps.textContent = formatValue(burstDps); outputs.sustainedDps.textContent = formatValue(sustainedDps); outputs.timeToEmpty.textContent = formatValue(timeToEmpty, 's'); outputs.magDamage.textContent = formatValue(magDamage); outputs.ttk.textContent = formatValue(ttk, 's'); updateActiveModifierList(modifiers); }; const setFormValue = (name, value) => { const field = form.elements[name]; if (!field) { return; } field.value = value ?? ''; }; const applyWeapon = (weapon) => { if (!weapon) { return; } setFormValue('weaponName', weapon.name ?? ''); setFormValue('baseDamage', weapon.damage ?? ''); setFormValue('projectiles', weapon.projectiles ?? weapon.damage_multiplier ?? 1); setFormValue('rpm', weapon.rpm ?? ''); setFormValue('magazine', weapon.magazine ?? weapon.mag_size ?? ''); setFormValue('reload', weapon.reload ?? weapon.reload_time ?? ''); currentWeaponTypes = weapon.types ?? weapon.guntype ?? []; calculate(); }; const normalizeModifier = (modifier, fallbackLabel) => { if (!modifier) { return null; } const normalized = { id: modifier.id, label: modifier.label ?? fallbackLabel ?? 'Unnamed modifier', operatives: modifier.operatives, appliesTo: modifier.appliesTo }; if (Number.isFinite(modifier.value) && modifier.type) { normalized.value = modifier.value; normalized.type = modifier.type; } if (Array.isArray(modifier.buffs)) { normalized.buffs = modifier.buffs; } return normalized; }; const inferSlot = (weapon) => { const directSlot = weapon.slot?.toUpperCase(); if (directSlot === 'PRIMARY' || directSlot === 'SECONDARY' || directSlot === 'SPECIAL') { return directSlot; } const types = weapon.types ?? weapon.guntype ?? []; if (types.includes('SECONDARY')) { return 'SECONDARY'; } if (types.includes('SPECIAL')) { return 'SPECIAL'; } return 'PRIMARY'; }; const normalizeWeapon = (weapon) => ({ id: weapon.id ?? weapon.name, name: weapon.name, damage: weapon.damage, projectiles: weapon.projectiles ?? weapon.damage_multiplier ?? 1, rpm: weapon.rpm, magazine: weapon.magazine ?? weapon.mag_size, reload: weapon.reload ?? weapon.reload_time, types: weapon.types ?? weapon.guntype ?? [], slot: inferSlot(weapon), operatives: Array.isArray(weapon.operatives) ? weapon.operatives : [] }); const selectedOperativeId = () => operativeSelect?.value ?? ''; const selectedSlot = () => (loadoutSlotSelect?.value ?? 'primary').toUpperCase(); const weaponAllowedForOperative = (weapon, operativeId) => { if (!operativeId) { return false; } if (!weapon.operatives.length) { return false; } return weapon.operatives.includes('*') || weapon.operatives.includes(operativeId); }; const refreshWeaponSelect = () => { if (!weaponSelect) { return; } const operativeId = selectedOperativeId(); const slot = selectedSlot(); const previousValue = weaponSelect.value; weaponSelect.innerHTML = ''; const defaultOption = document.createElement('option'); defaultOption.value = ''; defaultOption.textContent = operativeId ? 'Custom entry' : 'Select operative first'; weaponSelect.appendChild(defaultOption); weaponsById.clear(); if (!operativeId) { weaponSelect.disabled = true; return; } weaponSelect.disabled = false; const filtered = allWeapons .filter((weapon) => weapon.slot === slot) .filter((weapon) => weaponAllowedForOperative(weapon, operativeId)) .sort((a, b) => a.name.localeCompare(b.name)); filtered.forEach((weapon) => { const key = String(weapon.id); weaponsById.set(key, weapon); const option = document.createElement('option'); option.value = key; option.textContent = weapon.name; weaponSelect.appendChild(option); }); if (previousValue && weaponsById.has(previousValue)) { weaponSelect.value = previousValue; } else { weaponSelect.value = ''; } }; const loadWeaponLibrary = async () => { if (!weaponSelect) { return; } const source = weaponSelect.dataset.source; if (!source) { return; } try { const response = await fetch(source); if (!response.ok) { throw new Error('Failed to load weapons'); } const weapons = await response.json(); allWeapons = weapons .filter((weapon) => weapon && weapon.name) .map((weapon) => normalizeWeapon(weapon)); refreshWeaponSelect(); } catch (error) { console.error('Weapon library load failed', error); const option = document.createElement('option'); option.textContent = 'Weapon library unavailable'; option.disabled = true; weaponSelect.appendChild(option); weaponSelect.disabled = true; } }; const createModifierOption = (modifier, container) => { if (!modifier || !container) { return; } const id = modifier.id ?? `${modifier.label}-${Math.random().toString(36).slice(2)}`; modifiersById.set(id, modifier); const wrapper = document.createElement('label'); wrapper.className = 'af-checkbox'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.dataset.modifierId = id; if (modifier.operatives) { checkbox.dataset.operatives = modifier.operatives; } if (modifier.appliesTo) { checkbox.dataset.appliesTo = modifier.appliesTo; } const label = document.createElement('span'); label.textContent = modifier.label ?? 'Unnamed modifier'; wrapper.appendChild(checkbox); wrapper.appendChild(label); container.appendChild(wrapper); }; const loadModifierLibrary = async () => { if (!modifierStack || !modifierStack.dataset.source) { return; } try { const response = await fetch(modifierStack.dataset.source); if (!response.ok) { throw new Error('Failed to load modifiers'); } const payload = await response.json(); itemModifierList.innerHTML = ''; perkModifierList.innerHTML = ''; abilityModifierList.innerHTML = ''; payload.itemBuffs?.forEach((modifier) => createModifierOption(modifier, itemModifierList)); payload.operativePerks?.forEach((modifier) => createModifierOption(modifier, perkModifierList)); payload.abilityPerks?.forEach((modifier) => createModifierOption(modifier, abilityModifierList)); updateModifierAvailability(); } catch (error) { console.error('Modifier library load failed', error); const errorNote = document.createElement('p'); errorNote.className = 'af-empty-hint'; errorNote.textContent = 'Modifier library unavailable.'; itemModifierList.appendChild(errorNote.cloneNode(true)); perkModifierList.appendChild(errorNote.cloneNode(true)); abilityModifierList.appendChild(errorNote); } }; const renderModifierBucket = (container, modifiers, emptyText) => { if (!container) { return; } container.innerHTML = ''; if (!modifiers.length) { const placeholder = document.createElement('p'); placeholder.className = 'af-empty-hint'; placeholder.textContent = emptyText; container.appendChild(placeholder); return; } modifiers.forEach((modifier) => createModifierOption(modifier, container)); }; const renderOperativeModifiers = (operative) => { const operativePerks = Array.isArray(operative?.modifiers) ? operative.modifiers.map((mod) => normalizeModifier(mod)).filter(Boolean) : []; const operativeAbilities = Array.isArray(operative?.abilities) ? operative.abilities .map((ability, index) => normalizeModifier(ability?.modifier, ability?.label ?? `Ability ${index + 1}`)) .filter(Boolean) : []; const armorBuffs = operative?.armor?.modifier ? [normalizeModifier(operative.armor.modifier, operative.armor.label)].filter(Boolean) : []; renderModifierBucket(operativeModifierList, operativePerks, 'No operative perk modifiers available.'); renderModifierBucket(operativeAbilityModifierList, operativeAbilities, 'No operative ability buffs available.'); renderModifierBucket(operativeArmorModifierList, armorBuffs, 'No operative armor buffs available.'); }; const loadOperatives = async () => { if (!operativeSelect || !operativeSelect.dataset.source) { return; } try { const response = await fetch(operativeSelect.dataset.source); if (!response.ok) { throw new Error('Failed to load operatives'); } const payload = await response.json(); payload.operatives?.forEach((operative) => { if (!operative || !operative.name) { return; } operativesById.set(operative.id, operative); const option = document.createElement('option'); option.value = operative.id; option.textContent = operative.class ? `${operative.name} (${operative.class})` : operative.name; option.dataset.category = operative.category ?? operative.class ?? ''; operativeSelect.appendChild(option); }); updateModifierAvailability(); } catch (error) { console.error('Operative load failed', error); const option = document.createElement('option'); option.textContent = 'Operatives unavailable'; option.disabled = true; operativeSelect.appendChild(option); } }; const updateModifierAvailability = () => { const checkboxes = document.querySelectorAll('[data-modifier-id]'); checkboxes.forEach((checkbox) => { const requiredOperative = checkbox.dataset.operatives; const requiredType = checkbox.dataset.appliesTo; const wrapper = checkbox.closest('.af-checkbox'); const operativeMatch = requiredOperative ? matchesOperative(requiredOperative) : true; const typeMatch = requiredType ? matchesWeaponType(requiredType) : true; const isEnabled = operativeMatch && typeMatch; checkbox.disabled = !isEnabled; if (wrapper) { wrapper.classList.toggle('is-disabled', !isEnabled); } if (!isEnabled) { checkbox.checked = false; } }); }; form.addEventListener('input', calculate); resetButton.addEventListener('click', () => { form.reset(); document.querySelectorAll('[data-modifier-id]:checked').forEach((checkbox) => { checkbox.checked = false; }); renderOperativeModifiers(null); currentWeaponTypes = []; currentOperativeCategory = null; if (loadoutSlotSelect) { loadoutSlotSelect.value = 'primary'; loadoutSlotSelect.disabled = true; } if (weaponSelect) { weaponSelect.disabled = true; } refreshWeaponSelect(); updateModifierAvailability(); calculate(); }); if (weaponSelect) { weaponSelect.disabled = true; weaponSelect.addEventListener('change', (event) => { const selected = weaponsById.get(event.target.value); if (!selected) { currentWeaponTypes = []; updateModifierAvailability(); return; } applyWeapon(selected); updateModifierAvailability(); }); } if (operativeSelect) { operativeSelect.addEventListener('change', (event) => { const selected = operativesById.get(event.target.value); currentOperativeCategory = selected?.category ?? selected?.class ?? null; renderOperativeModifiers(selected); if (loadoutSlotSelect) { loadoutSlotSelect.disabled = !selected; } refreshWeaponSelect(); setFormValue('weaponName', ''); setFormValue('baseDamage', ''); setFormValue('projectiles', 1); setFormValue('rpm', ''); setFormValue('magazine', ''); setFormValue('reload', ''); currentWeaponTypes = []; updateModifierAvailability(); calculate(); }); } if (loadoutSlotSelect) { loadoutSlotSelect.disabled = true; loadoutSlotSelect.addEventListener('change', () => { refreshWeaponSelect(); setFormValue('weaponSelect', ''); setFormValue('weaponName', ''); setFormValue('baseDamage', ''); setFormValue('projectiles', 1); setFormValue('rpm', ''); setFormValue('magazine', ''); setFormValue('reload', ''); currentWeaponTypes = []; updateModifierAvailability(); calculate(); }); } document.addEventListener('change', (event) => { if (event.target && event.target.matches('[data-modifier-id]')) { calculate(); } }); copyButton.addEventListener('click', async () => { const data = new FormData(form); const name = data.get('weaponName') || 'Unnamed loadout'; const payload = [ `Loadout: ${name}`, `Damage per shot: ${outputs.shotDamage.textContent}`, `Burst DPS: ${outputs.burstDps.textContent}`, `Sustained DPS: ${outputs.sustainedDps.textContent}`, `Time to empty: ${outputs.timeToEmpty.textContent}`, `Magazine damage: ${outputs.magDamage.textContent}`, `Time to eliminate: ${outputs.ttk.textContent}` ].join('\n'); try { await navigator.clipboard.writeText(payload); copyButton.textContent = 'Copied!'; setTimeout(() => { copyButton.textContent = 'Copy summary'; }, 2000); } catch (error) { console.error('Clipboard copy failed', error); copyButton.textContent = 'Copy failed'; setTimeout(() => { copyButton.textContent = 'Copy summary'; }, 2000); } }); calculate(); renderOperativeModifiers(null); loadWeaponLibrary(); loadModifierLibrary(); loadOperatives();