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 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 activeModifierList = document.getElementById('af-active-modifiers'); 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 (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: rpm, reload: 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.damage_multiplier ?? 1); setFormValue('rpm', weapon.rpm ?? ''); setFormValue('magazine', weapon.mag_size ?? ''); setFormValue('reload', weapon.reload_time ?? ''); currentWeaponTypes = weapon.guntype ?? []; calculate(); }; 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(); const normalized = weapons .filter((weapon) => weapon && weapon.name) .map((weapon) => ({ id: weapon.id ?? weapon.name, name: weapon.name, damage: weapon.damage, damage_multiplier: weapon.damage_multiplier ?? 1, rpm: weapon.rpm, mag_size: weapon.mag_size, reload_time: weapon.reload_time, guntype: weapon.guntype ?? [] })) .sort((a, b) => a.name.localeCompare(b.name)); normalized.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); }); } 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); } }; 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 renderOperativeModifiers = (operative) => { if (!operativeModifierList) { return; } operativeModifierList.innerHTML = ''; if (!operative || !Array.isArray(operative.modifiers) || !operative.modifiers.length) { const placeholder = document.createElement('p'); placeholder.className = 'af-empty-hint'; placeholder.textContent = 'No operative-specific modifiers available.'; operativeModifierList.appendChild(placeholder); return; } operative.modifiers.forEach((modifier) => createModifierOption(modifier, operativeModifierList)); }; 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.name; option.dataset.category = operative.category ?? ''; 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(); currentWeaponTypes = []; updateModifierAvailability(); calculate(); }); if (weaponSelect) { 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 ?? null; renderOperativeModifiers(selected); 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(); loadWeaponLibrary(); loadModifierLibrary(); loadOperatives();