/** * Internal script to analyze damage and spit out results. * There isn't enough entries in the database to make a full analysis, some tiers miss some types. * This script only checks for "minions" and "everything else". * Maybe if future book monsters can be part of what we release, we can analyze those too. */ import fs from 'fs/promises'; import path from 'path'; const allData = []; // Read adversary pack data for average damage for attacks const adversariesDirectory = path.join('src/packs/adversaries'); for (const basefile of await fs.readdir(adversariesDirectory)) { if (!basefile.endsWith('.json')) continue; const filepath = path.join(adversariesDirectory, basefile); const data = JSON.parse(await fs.readFile(filepath, 'utf8')); if (data?.type !== 'adversary' || data.system.type === 'social') continue; allData.push({ name: data.name, tier: data.system.tier, adversaryType: data.system.type, damage: parseDamage(data.system.attack.damage) }); } const adversaryTypes = new Set(allData.map(a => a.adversaryType)); for (const type of [...adversaryTypes].toSorted()) { const perTier = Object.groupBy( allData.filter(a => a.adversaryType === type), a => a.tier ); console.log(`${type} per Tier: ${[1, 2, 3, 4].map(t => perTier[t]?.length ?? 0).join(' ')}`); } const result = { basic: compileData(allData.filter(d => d.adversaryType !== 'minion')), solos_and_bruisers: compileData(allData.filter(d => ['solo', 'bruiser'].includes(d.adversaryType))), leader_and_ranged: compileData(allData.filter(d => ['leader', 'ranged'].includes(d.adversaryType))), minion: compileData(allData.filter(d => d.adversaryType === 'minion')) }; console.log(result); /** Compiles all data for an adversary type (or all entries) */ function compileData(entries) { // Note: sorting numbers sorts by their string version by default const results = {}; for (const tier of [1, 2, 3, 4]) { const tierEntries = entries.filter(e => e.tier === tier); const allDamage = removeOutliers(tierEntries.map(d => d.damage).sort((a, b) => a - b)); const mean = getMean(allDamage); if (tier === 4) console.log(allDamage); results[tier] = { mean, deviation: getStandardDeviation(allDamage, { mean }) }; } return results; } function removeOutliers(data) { if (data.length <= 4) return data; const startIdx = Math.floor(data.length * 0.25); const endIdx = Math.ceil(data.length * 0.75); const iqrBound = (data[endIdx] - data[startIdx]) * 1.25; return data.filter(d => d >= data[startIdx] - iqrBound && d <= data[endIdx] + iqrBound); } function getMedian(numbers) { numbers = numbers.toSorted((a, b) => a - b); const medianIdx = numbers.length / 2; return medianIdx % 1 ? numbers[Math.floor(medianIdx)] : (numbers[medianIdx] + numbers[medianIdx - 1]) / 2; } function getMean(numbers) { if (numbers.length === 0) return NaN; return numbers.reduce((r, a) => r + a, 0) / numbers.length; } function getMedianAverageDeviation(numbers, { median }) { const residuals = allDamage.map(d => Math.abs(d - median)); return getMedian(residuals); } function getStandardDeviation(numbers, { mean }) { const deviations = numbers.map(r => r - mean); return Math.sqrt(deviations.reduce((r, d) => r + d * d, 0) / (numbers.length - 1)); } function parseDamage(damage) { const formula = damage.parts .filter(p => p.applyTo === 'hitPoints') .map(p => p.value.custom.enabled ? p.value.custom.formula : [p.value.flatMultiplier ? `${p.value.flatMultiplier}${p.value.dice}` : 0, p.value.bonus ?? 0] .filter(p => !!p) .join('+') ) .join('+'); return getExpectedDamage(formula); } /** * Given a simple flavor-less formula with only +/- operators, returns a list of damage partial terms. * All subtracted terms become negative terms. */ function getExpectedDamage(formula) { const terms = formula .replace('+', ' + ') .replace('-', ' - ') .split(' ') .map(t => t.trim()); let multiplier = 1; return terms.reduce((total, term) => { if (term === '-') { multiplier = -1; return total; } else if (term === '+') { return total; } const currentMultiplier = multiplier; multiplier = 1; const number = Number(term); if (!Number.isNaN(number)) { return total + currentMultiplier * number; } const dieMatch = term.match(/(\d+)d(\d+)/); if (dieMatch) { const numDice = Number(dieMatch[1]); const faces = Number(dieMatch[2]); return total + currentMultiplier * numDice * ((faces + 1) / 2); } throw Error(`Unexpected term ${term} in formula ${formula}`); }, 0); }