Files
anomaly-front-stats/static/filterWeapons.js
T

665 lines
22 KiB
JavaScript

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 normalizeToArray = (value) => {
if (!value) {
return [];
}
if (Array.isArray(value)) {
return value.filter(Boolean);
}
return [value].filter(Boolean);
};
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) => {
const required = normalizeToArray(modifierOperative);
if (!required.length) {
return true;
}
if (!currentOperativeCategory) {
return false;
}
const operative = currentOperativeCategory.toLowerCase();
return required.some((entry) => entry.toLowerCase() === operative);
};
const matchesWeaponType = (appliesTo) => {
const required = normalizeToArray(appliesTo);
if (!required.length) {
return true;
}
if (!currentWeaponTypes.length) {
return false;
}
const available = currentWeaponTypes.map((type) => type.toUpperCase());
return required.some((type) => available.includes(type.toUpperCase()));
};
const matchesWeaponExclusion = (appliesNotTo) => {
const excluded = normalizeToArray(appliesNotTo);
if (!excluded.length) {
return true;
}
if (!currentWeaponTypes.length) {
return true;
}
const available = currentWeaponTypes.map((type) => type.toUpperCase());
return !excluded.some((type) => available.includes(type.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)
|| !matchesWeaponExclusion(modifier.appliesNotTo)
) {
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 projectilesRaw = numberOrNull(data.get('projectiles'));
const projectiles = projectilesRaw !== null ? Math.max(projectilesRaw, 1) : 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 === null || rpm === null || baseDamage < 0 || rpm <= 0) {
Object.values(outputs).forEach((node) => {
node.textContent = '--';
});
updateActiveModifierList(getSelectedModifiers());
return;
}
const modifiers = getSelectedModifiers();
const stats = modifiers.reduce(
(acc, modifier) => applyModifierToStats(modifier, acc),
{
damage: baseDamage,
rpm,
reload
}
);
const safeStats = {
damage: Math.max(stats.damage, 0),
rpm: Math.max(stats.rpm, 0),
reload: Number.isFinite(stats.reload) ? Math.max(stats.reload, 0) : stats.reload
};
const magazineSize = Number.isFinite(magazine) && magazine > 0 ? magazine : null;
const shotDamage = safeStats.damage * projectiles;
const shotsPerSecond = safeStats.rpm / 60;
const burstDps = shotDamage * shotsPerSecond;
const magDamage = magazineSize ? shotDamage * magazineSize : null;
const timeToEmpty = magazineSize ? magazineSize / shotsPerSecond : null;
let sustainedDps = null;
if (
magDamage !== null
&& Number.isFinite(safeStats.reload)
&& timeToEmpty !== null
&& (timeToEmpty + safeStats.reload) > 0
) {
sustainedDps = magDamage / (timeToEmpty + safeStats.reload);
}
let ttk = null;
if (targetHealth !== null && targetHealth >= 0 && 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,
appliesNotTo: modifier.appliesNotTo
};
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 = normalizeToArray(weapon.types ?? weapon.guntype ?? []);
if (types.includes('SECONDARY')) {
return 'SECONDARY';
}
if (types.includes('SPECIAL')) {
return 'SPECIAL';
}
return 'PRIMARY';
};
const normalizeWeapon = (weapon) => {
const types = normalizeToArray(weapon.types ?? weapon.guntype ?? []);
return {
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,
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;
}
let id = modifier.id ?? `${modifier.label}-${Math.random().toString(36).slice(2)}`;
while (modifiersById.has(id)) {
id = `${id}-${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;
}
if (modifier.appliesNotTo) {
checkbox.dataset.appliesNotTo = modifier.appliesNotTo;
}
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 excludedType = checkbox.dataset.appliesNotTo;
const wrapper = checkbox.closest('.af-checkbox');
const operativeMatch = requiredOperative ? matchesOperative(requiredOperative) : true;
const typeMatch = requiredType ? matchesWeaponType(requiredType) : true;
const exclusionMatch = excludedType ? matchesWeaponExclusion(excludedType) : true;
const isEnabled = operativeMatch && typeMatch && exclusionMatch;
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();