import {setupPopover} from './popover'; const roiCache: Record> = {}; async function getROI(cx: string) { const response = await fetch(`/roi_${cx.toLowerCase()}.json`); const lastModified = new Date(response.headers.get('last-modified')!); const profits = await response.json(); return {lastModified, profits}; } type MetricType = 'vwap' | 'bid' | 'ask'; const lowVolume = document.querySelector('input#low-volume') as HTMLInputElement; const cxSelect = document.querySelector('select#cx') as HTMLSelectElement; const expertise = { AGRICULTURE: 'agri', CHEMISTRY: 'chem', CONSTRUCTION: 'const', ELECTRONICS: 'elec', FOOD_INDUSTRIES: 'food ind', FUEL_REFINING: 'fuel', MANUFACTURING: 'mfg', METALLURGY: 'metal', RESOURCE_EXTRACTION: 'res ext', } as const; const expertiseSelect = document.querySelector('select#expertise') as HTMLSelectElement; for (const key of Object.keys(expertise)) { const option = document.createElement('option'); option.value = key; option.textContent = key.replace('_', ' ').toLowerCase(); expertiseSelect.appendChild(option); } const buildingSelect = document.querySelector('select#building') as HTMLSelectElement; const formatSigFig = new Intl.NumberFormat(undefined, { notation: 'compact', maximumSignificantDigits: 3, }).format; if (localStorage.getItem('roi-cx')) cxSelect.value = localStorage.getItem('roi-cx')!; if (localStorage.getItem('roi-expertise')) expertiseSelect.value = localStorage.getItem('roi-expertise')!; if (localStorage.getItem('roi-low-volume')) lowVolume.checked = localStorage.getItem('roi-low-volume') === 'true'; let savedBuilding = localStorage.getItem('roi-building') || ''; let storedSortKey = localStorage.getItem('roi-sort-key') as any; if (storedSortKey === 'logistics_per_base') storedSortKey = 'normalized_logistics_per_base'; let currentSortKey: keyof ProfitWithMetrics | 'outputs' = storedSortKey || 'break_even'; let currentSortAsc: boolean = localStorage.getItem('roi-sort-asc') !== 'false'; let headersInitialized = false; let metricControlsInitialized = false; let capexMetric: MetricType = (localStorage.getItem('roi-capex-metric') as MetricType) || 'vwap'; let opexMetric: MetricType = (localStorage.getItem('roi-opex-metric') as MetricType) || 'vwap'; let revenueMetric: MetricType = (localStorage.getItem('roi-revenue-metric') as MetricType) || 'vwap'; let roundTripOption: string = localStorage.getItem('roi-round-trip') || 'omit'; let showNegativeProfit: boolean = localStorage.getItem('roi-show-negative') !== 'false'; let workingCapitalOption: string = localStorage.getItem('roi-working-capital-opt') || 'dynamic'; let targetPermitOption: string = localStorage.getItem('roi-target-permit') || '2'; let percentileMode: 'relative' | 'absolute' = (localStorage.getItem('roi-pct-mode') as 'relative' | 'absolute') || 'relative'; async function render() { const tbody = document.querySelector('tbody')!; tbody.innerHTML = ''; const cx = cxSelect.value; if (!roiCache[cx]) roiCache[cx] = getROI(cx); const {lastModified, profits} = await roiCache[cx]; if (!metricControlsInitialized) { const controls = document.createElement('div'); controls.style.marginBottom = '15px'; controls.innerHTML = ` `; const table = document.querySelector('table'); if (table) table.parentNode?.insertBefore(controls, table); (document.getElementById('capex-metric') as HTMLSelectElement).value = capexMetric; (document.getElementById('opex-metric') as HTMLSelectElement).value = opexMetric; (document.getElementById('revenue-metric') as HTMLSelectElement).value = revenueMetric; (document.getElementById('show-negative') as HTMLInputElement).checked = showNegativeProfit; (document.getElementById('round-trip') as HTMLSelectElement).value = roundTripOption; (document.getElementById('working-capital') as HTMLSelectElement).value = workingCapitalOption; (document.getElementById('target-permit') as HTMLSelectElement).value = targetPermitOption; (document.getElementById('percentile-mode') as HTMLSelectElement).value = percentileMode; document.getElementById('capex-metric')!.addEventListener('change', (e) => { capexMetric = (e.target as HTMLSelectElement).value as MetricType; render(); }); document.getElementById('opex-metric')!.addEventListener('change', (e) => { opexMetric = (e.target as HTMLSelectElement).value as MetricType; render(); }); document.getElementById('revenue-metric')!.addEventListener('change', (e) => { revenueMetric = (e.target as HTMLSelectElement).value as MetricType; render(); }); document.getElementById('show-negative')!.addEventListener('change', (e) => { showNegativeProfit = (e.target as HTMLInputElement).checked; render(); }); document.getElementById('round-trip')!.addEventListener('change', (e) => { roundTripOption = (e.target as HTMLSelectElement).value; render(); }); document.getElementById('working-capital')!.addEventListener('change', (e) => { workingCapitalOption = (e.target as HTMLSelectElement).value; render(); }); document.getElementById('target-permit')!.addEventListener('change', (e) => { targetPermitOption = (e.target as HTMLSelectElement).value; render(); }); document.getElementById('percentile-mode')!.addEventListener('change', (e) => { percentileMode = (e.target as HTMLSelectElement).value as 'relative' | 'absolute'; render(); }); metricControlsInitialized = true; } if (!headersInitialized) { const ths = document.querySelectorAll('th'); const keys: (keyof ProfitWithMetrics | 'outputs')[] = [ 'outputs', 'expertise', 'profit_per_base', 'break_even',  'capex_val', 'opex_val', 'normalized_logistics_per_base', 'market_capacity_base' ]; const pctExplainer = '\n(Percentiles: 100.0% is the most desirable outcome. Toggle between Absolute and Normalized (weighted by market cash flow) using the controls.)'; ths.forEach((th, i) => { if (keys[i]) { th.style.cursor = 'pointer'; th.title = '';  if (keys[i] === 'profit_per_base') { th.textContent = 'Profit/Base'; th.dataset.tooltip = 'Click to sort.\n\nDaily profit scaled to a full 500-area planetary base.' + pctExplainer; } else if (keys[i] === 'capex_val') { th.textContent = 'CapEx/Base'; th.dataset.tooltip = 'Click to sort.\n\nTotal capital expenditure scaled to a full 500-area planetary base.\nIncludes base construction, and optional Working Capital, HQ Upgrades, and Ship CapEx (use "Omit From Calculation" to exclude).' + pctExplainer; } else if (keys[i] === 'opex_val') { th.textContent = 'OpEx/Base'; th.dataset.tooltip = 'Click to sort.\n\nDaily operational expenditure (input materials + worker consumables) scaled to a full 500-area planetary base.' + pctExplainer; } else if (keys[i] === 'normalized_logistics_per_base') { th.textContent = 'Logistics/Base'; th.dataset.tooltip = 'Click to sort.\n\nDaily logistics bottleneck scaled to a full 500-area planetary base. The suffix indicates whether Weight (t) or Volume (m³) of Inputs (I) or Outputs (O) is the limiting bottleneck.\nSorts and percentiles are strictly normalized based on ship capacity limits (3000t or 1000m³).' + pctExplainer; } else if (keys[i] === 'market_capacity_base') { th.textContent = 'Market Cap (Bases)'; th.dataset.tooltip = 'Click to sort.\n\nMarket Capacity: 7-day average traded volume ÷ daily output per base. Indicates how many full 500-area bases you can build before saturating the market.' + pctExplainer; } else if (keys[i] === 'break_even') { th.dataset.tooltip = 'Click to sort.\n\nBreak Even: CapEx ÷ daily profit. Note that CapEx dynamically includes optional logistics and HQ upgrades to accurately reflect operational readiness.' + pctExplainer; } else { th.dataset.tooltip = 'Click to sort.'; } th.addEventListener('click', () => { if (currentSortKey === keys[i]) { currentSortAsc = !currentSortAsc; } else { currentSortKey = keys[i]; currentSortAsc = keys[i] === 'break_even' ? true : false; } render(); }); } }); headersInitialized = true; } const buildingTickers = new Set(profits.map(p => p.building)); const buildings: {ticker: string, expertise: keyof typeof expertise}[] = Array.from(buildingTickers) .map((building) => ({ticker: building, expertise: profits.find(p => p.building === building)!.expertise})) .sort((a, b) => a.ticker.localeCompare(b.ticker)); let selectedBuilding = buildingSelect.value || savedBuilding; let buildingFound = false; buildingSelect.innerHTML = ''; for (const building of buildings) if (expertiseSelect.value === '' || expertiseSelect.value === building.expertise) { const option = document.createElement('option'); option.value = building.ticker; option.textContent = building.ticker; if (building.ticker === selectedBuilding) { buildingFound = true; option.selected = true; } buildingSelect.appendChild(option); } if (!buildingFound) selectedBuilding = ''; savedBuilding = '';  const filteredProfits = profits.filter(p => { const volumeRatio = p.output_per_day / p.average_traded_7d; if (!lowVolume.checked && volumeRatio > 0.05) return false; if (expertiseSelect.value !== '' && p.expertise !== expertiseSelect.value) return false; if (selectedBuilding !== '' && p.building !== selectedBuilding) return false; return true; }); let profitsWithMetrics: ProfitWithMetrics[] = filteredProfits.map(p => { const bases = p.area / 500; const opex_val = p.opex[opexMetric] / bases; const revenue_val = p.revenue[revenueMetric] / bases; let capex_val = p.capex[capexMetric] / bases; let activeWorkingCapitalDays = 0; if (workingCapitalOption !== 'omit') { if (workingCapitalOption === 'dynamic') { activeWorkingCapitalDays = p.normalized_logistics_per_base > 0  ? 1 / p.normalized_logistics_per_base  : 0; } else { activeWorkingCapitalDays = parseInt(workingCapitalOption, 10); } capex_val += (opex_val * activeWorkingCapitalDays); } let hq_capex = 0; if (targetPermitOption !== 'omit') { const targetPermit = parseInt(targetPermitOption, 10); if (targetPermit >= 3) { const hqLevelStr = (targetPermit - 1).toString(); if (p.hq_costs && p.hq_costs[hqLevelStr]) { hq_capex = p.hq_costs[hqLevelStr][capexMetric]; capex_val += hq_capex; } } } let shipsNeeded = 0; let activeShipCapex = 0; if (roundTripOption !== 'omit') { const roundTripHours = parseInt(roundTripOption, 10); shipsNeeded = p.normalized_logistics_per_base * (roundTripHours / 24); activeShipCapex = shipsNeeded * 800_000; capex_val += activeShipCapex; } const profit_per_base = revenue_val - opex_val; const break_even = profit_per_base > 0 ? capex_val / profit_per_base : Infinity; // EXTREME DETAIL: We determine the specific market weight of this recipe line. // Multiplying the revenue (value per day per base) by the market capacity (max bases allowed) // yields the total cash flow value of all available trades in the global FIO market for this bottleneck. const market_cash_flow = revenue_val * p.market_capacity_base; return {  ...p,  capex_val,  opex_val,  revenue_val,  profit_per_base,  break_even,  hq_capex, activeWorkingCapitalDays, activeShipCapex, shipsNeeded, market_cash_flow }; }); if (!showNegativeProfit) { profitsWithMetrics = profitsWithMetrics.filter(p => p.profit_per_base > 0); } // EXTREME DETAIL: Overhauled the extraction arrays to store both the numerical value AND the weight parameter. const numSortObj = (a: {val: number, weight: number}, b: {val: number, weight: number}) => (a.val < b.val ? -1 : a.val > b.val ? 1 : 0); const arrProfit = profitsWithMetrics.map(p => ({val: p.profit_per_base, weight: p.market_cash_flow})).sort(numSortObj); const arrBreak = profitsWithMetrics.map(p => ({val: p.break_even, weight: p.market_cash_flow})).sort(numSortObj); const arrCapex = profitsWithMetrics.map(p => ({val: p.capex_val, weight: p.market_cash_flow})).sort(numSortObj); const arrOpex = profitsWithMetrics.map(p => ({val: p.opex_val, weight: p.market_cash_flow})).sort(numSortObj); const arrLog = profitsWithMetrics.map(p => ({val: p.normalized_logistics_per_base, weight: p.market_cash_flow})).sort(numSortObj); const arrCap = profitsWithMetrics.map(p => ({val: p.market_capacity_base, weight: p.market_cash_flow})).sort(numSortObj); profitsWithMetrics.sort((a, b) => { let valA: any = a[currentSortKey as keyof ProfitWithMetrics]; let valB: any = b[currentSortKey as keyof ProfitWithMetrics]; if (currentSortKey === 'outputs') { valA = a.outputs.map(o => o.ticker).join(', '); valB = b.outputs.map(o => o.ticker).join(', '); } if (valA < valB) return currentSortAsc ? -1 : 1; if (valA > valB) return currentSortAsc ? 1 : -1; return 0; }); for (const p of profitsWithMetrics) { const tr = document.createElement('tr'); // Map the raw value to the selected percentile rank. const pctProfit = getPercentiles(p.profit_per_base, arrProfit, false)[percentileMode === 'absolute' ? 'abs' : 'rel']; const pctBreak = getPercentiles(p.break_even, arrBreak, true)[percentileMode === 'absolute' ? 'abs' : 'rel']; const pctCapex = getPercentiles(p.capex_val, arrCapex, true)[percentileMode === 'absolute' ? 'abs' : 'rel']; const pctOpex = getPercentiles(p.opex_val, arrOpex, true)[percentileMode === 'absolute' ? 'abs' : 'rel']; const pctLog = getPercentiles(p.normalized_logistics_per_base, arrLog, true)[percentileMode === 'absolute' ? 'abs' : 'rel']; const pctCap = getPercentiles(p.market_capacity_base, arrCap, false)[percentileMode === 'absolute' ? 'abs' : 'rel']; // Interplate the format string requested directly into the span wrapper. // EXTREME DETAIL: Replaced all space characters right before the wrappers with
tags // to guarantee consistent vertical stacking of the main value and the percentile regardless of CSS flex/wrapping behaviors. tr.innerHTML = ` ${p.outputs.map(o => o.ticker).join(', ')} ${expertise[p.expertise]} ${formatSigFig(p.profit_per_base)}
${pctProfit} ${formatSigFig(p.break_even)}d
${pctBreak} ${formatSigFig(p.capex_val)}
${pctCapex} ${formatSigFig(p.opex_val)}
${pctOpex} ${formatSigFig(p.logistics_per_base)} ${p.logistics_bottleneck}
${pctLog} ${formatSigFig(p.market_capacity_base)}
${pctCap} `; const output = tr.querySelector('td')!; output.dataset.tooltip = p.recipe; const profitCell = tr.querySelectorAll('td')[2]; const runs_per_base = p.runs_per_day / (p.area / 500); profitCell.dataset.tooltip = formatMatPrices(p.outputs, revenueMetric, runs_per_base) + '\n\n' + formatMatPrices(p.input_costs, opexMetric, runs_per_base) + '\n' + '+ worker consumables\n\n' + `(${formatSigFig(p.revenue_val)} - ${formatSigFig(p.opex_val)}) = ${formatSigFig(p.profit_per_base)}`; const capexCell = tr.querySelectorAll('td')[4]; capexCell.dataset.tooltip = `Base Construction: ${formatSigFig(p.capex[capexMetric] / (p.area / 500))}`; if (workingCapitalOption !== 'omit') { capexCell.dataset.tooltip += `\nWorking Capital (${formatSigFig(p.activeWorkingCapitalDays)} days): ${formatSigFig(p.opex_val * p.activeWorkingCapitalDays)}`; } if (targetPermitOption !== 'omit' && p.hq_capex > 0) { capexCell.dataset.tooltip += `\nHQ Upgrade (Permit ${targetPermitOption}): ${formatSigFig(p.hq_capex)}`; } if (roundTripOption !== 'omit') { capexCell.dataset.tooltip += `\nShip CapEx: ${formatSigFig(p.activeShipCapex)} (${formatSigFig(p.shipsNeeded)} ships)`; } const marketCell = tr.querySelectorAll('td')[7]; marketCell.dataset.tooltip = `Market Capacity: ${formatSigFig(p.average_traded_7d)} traded/day ÷ ${formatSigFig(p.output_per_day / (p.area / 500))} produced/day/base = ${formatSigFig(p.market_capacity_base)} equivalent bases`; tbody.appendChild(tr); } document.getElementById('last-updated')!.textContent = `last updated: ${lastModified.toLocaleString(undefined, {dateStyle: 'full', timeStyle: 'long', hour12: false})}`; saveState(); } // EXTREME DETAIL: Overhauled function to calculate both Absolute and Relative Volume-Weighted Percentiles. // To ensure the mathematical max bounds cleanly hit 100.0%, the denominator is extracted dynamically  // relative to the highest data point present in the array. function getPercentiles(val: number, sortedArr: {val: number, weight: number}[], invert: boolean = false): {abs: string, rel: string} { if (sortedArr.length < 2) return {abs: "100.0%", rel: "100.0%"}; let lessCount = 0; let lessWeight = 0; for (let i = 0; i < sortedArr.length; i++) { if (sortedArr[i].val < val) { lessCount++; lessWeight += sortedArr[i].weight; } else { break;  } } const maxVal = sortedArr[sortedArr.length - 1].val; let maxLessCount = 0; let maxLessWeight = 0; for (let i = 0; i < sortedArr.length; i++) { if (sortedArr[i].val < maxVal) { maxLessCount++; maxLessWeight += sortedArr[i].weight; } else { break; } } let absDecimal = maxLessCount > 0 ? lessCount / maxLessCount : 1.0; let relDecimal = maxLessWeight > 0 ? lessWeight / maxLessWeight : 1.0; if (invert) { absDecimal = 1.0 - absDecimal; relDecimal = 1.0 - relDecimal; } return { abs: (absDecimal * 100).toFixed(1) + "%", rel: (relDecimal * 100).toFixed(1) + "%" }; } function saveState() { localStorage.setItem('roi-cx', cxSelect.value); localStorage.setItem('roi-expertise', expertiseSelect.value); localStorage.setItem('roi-building', buildingSelect.value); localStorage.setItem('roi-low-volume', lowVolume.checked.toString()); localStorage.setItem('roi-sort-key', currentSortKey); localStorage.setItem('roi-sort-asc', currentSortAsc.toString()); localStorage.setItem('roi-capex-metric', capexMetric); localStorage.setItem('roi-opex-metric', opexMetric); localStorage.setItem('roi-revenue-metric', revenueMetric); localStorage.setItem('roi-show-negative', showNegativeProfit.toString()); localStorage.setItem('roi-round-trip', roundTripOption); localStorage.setItem('roi-working-capital-opt', workingCapitalOption); localStorage.setItem('roi-target-permit', targetPermitOption); localStorage.setItem('roi-pct-mode', percentileMode); } function color(n: number, low: number, high: number): string { const scale = Math.min(Math.max((n - low) / (high - low), 0), 1); return `color-mix(in xyz, #0aa ${scale * 100}%, #f80)`; } function formatMatPrices(matPrices: MatPrice[], metric: MetricType, runs_per_day: number): string { return matPrices.map(({ticker, amount, vwap_7d, bid, ask}) => { const val = metric === 'vwap' ? vwap_7d : metric === 'bid' ? (bid ?? vwap_7d) : (ask ?? vwap_7d); const daily_amount = amount * runs_per_day; return `${ticker}: ${formatSigFig(daily_amount)} × ${formatSigFig(val)} = ${formatSigFig(daily_amount * val)}`; }).join('\n'); } // EXTREME DETAIL: Injects a comprehensive, casually written documentation section at the bottom of the page. // This demystifies the PrUn API data pipeline and exposes the raw formulas used for the table's metrics. function initDocumentation() { // Prevent duplicate documentation blocks if initialized multiple times (e.g. HMR or weird state) if (document.getElementById('methodology-doc')) return; const docDetails = document.createElement('details'); docDetails.id = 'methodology-doc'; docDetails.style.marginTop = '40px'; docDetails.style.marginBottom = '40px'; docDetails.style.padding = '15px'; docDetails.style.border = '1px solid #444'; docDetails.style.borderRadius = '8px'; docDetails.style.backgroundColor = 'rgba(128, 128, 128, 0.1)'; docDetails.innerHTML = ` 📖 Documentation: How These Numbers Are Calculated

This tool pulls live data from the PrUn APIs and calculates the true profitability of every recipe in the game. Here is the step-by-step math from the raw data to your screen:

1. The "Base" Normalization

To make fair comparisons between a tiny Farm and a massive Shipyard, all metrics are scaled to a standard 500-area planetary base. First, we calculate the true area of a building by adding its base area footprint to the area of the habitation modules required for its specific workers. Then, we divide 500 by this true area to figure out exactly how many of these fully-staffed buildings fit on one planet.

2. Revenue & OpEx (Operational Expenditure)

Revenue: Daily output amount × Runs per day × Selected Price (VWAP, Bid, or Ask).

OpEx: (Daily input amount × Selected Price) + Worker Consumables. The consumables cost is calculated by looking up the exact daily drinking water, rations, luxury goods, etc., needed to keep your specific workforce alive and operating at 100%.

Profit/Base: Total Daily Revenue - Total Daily OpEx.

3. CapEx (Capital Expenditure) & Break Even

CapEx starts with the exact material costs to construct the buildings (plus MCG/BSE/etc. for the planetary surface). Using the dropdown toggles at the top, it dynamically expands to include:

  • Working Capital: Adds your daily OpEx multiplied by the number of days you need to buffer in a warehouse.
  • HQ Upgrades: Adds the material costs of upgrading your Headquarters to the selected Permit level.
  • Ship CapEx: Adds a flat 800,000 CIS per ship required to move your goods (calculated dynamically based on your Logistics Bottleneck and the Round Trip time).

Break Even: Total CapEx ÷ Daily Profit. This tells you exactly how many days it takes for the entire setup to pay for itself.

4. Logistics Bottleneck

Every recipe requires shipping inputs in and outputs out. We calculate the total Weight (t) and Volume (m³) for both. We then divide these by a standard ship's capacity (3,000t or 1,000m³). The highest resulting number is your Logistics Bottleneck. For example, if a recipe fills 1.5 ships per day with output volume, your bottleneck is "1.5 m³ (O)".

5. Market Capacity (Saturation)

Even if a recipe is insanely profitable, it doesn't matter if nobody is buying it. We divide the 7-Day Average Traded Volume on the selected exchange by the Daily Output of a Full 500-Area Base. If the Market Capacity is "0.5", it means building just half a base will completely saturate the market and crash the price.

6. Percentiles (Normalized vs Absolute)

Percentiles rank every recipe from 0.0% (worst) to 100.0% (best).
Absolute percentiles treat every recipe equally (e.g., 1 vote per recipe).
Normalized percentiles weight each recipe by its Market Cash Flow (Revenue × Market Capacity). This prevents thinly traded, obscure recipes from artificially skewing the rankings, giving you a much more realistic view of the overall economy.

`; const table = document.querySelector('table'); if (table && table.parentNode) { table.parentNode.appendChild(docDetails); } else { document.body.appendChild(docDetails); } } setupPopover(); lowVolume.addEventListener('change', render); cxSelect.addEventListener('change', render); expertiseSelect.addEventListener('change', render); buildingSelect.addEventListener('change', render); render(); initDocumentation(); interface Metrics { vwap: number; bid: number; ask: number; } interface Profit { outputs: MatPrice[] recipe: string expertise: keyof typeof expertise building: string area: number capex: Metrics opex: Metrics revenue: Metrics input_costs: MatPrice[] runs_per_day: number logistics_per_base: number normalized_logistics_per_base: number logistics_bottleneck: string output_per_day: number average_traded_7d: number market_capacity_base: number hq_costs: Record } interface ProfitWithMetrics extends Profit { capex_val: number; opex_val: number; revenue_val: number; profit_per_day: number; profit_per_base: number; break_even: number; hq_capex: number; activeWorkingCapitalDays: number; activeShipCapex: number; shipsNeeded: number; market_cash_flow: number; // Exported weight tracker } interface MatPrice { ticker: string amount: number vwap_7d: number bid: number | null ask: number | null } interface Building { building_type: 'INFRASTRUCTURE' | 'PLANETARY' | 'PRODUCTION'; building_ticker: string; expertise: keyof typeof expertise; }