| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384 |
- import {setupPopover} from './popover';
- const roiCache: Record<string, Promise<{lastModified: Date, profits: Profit[]}>> = {};
- 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 currentSortKey: keyof ProfitWithMetrics | 'outputs' = (localStorage.getItem('roi-sort-key') as any) || '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 includeShips: boolean = localStorage.getItem('roi-include-ships') === 'true';
- let workingCapitalDays: number = parseInt(localStorage.getItem('roi-working-capital') || '3', 10);
- // EXTREME DETAIL: Track the state of the Target Permit. Defaults to 2 (the standard starting permits in PRUN).
- let targetPermit: number = parseInt(localStorage.getItem('roi-target-permit') || '2', 10);
- 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';
- // EXTREME DETAIL: Injected the new `<input type="number">` for Target Permit.
- controls.innerHTML = `
- <label style="margin-right: 15px;">CapEx Price:
- <select id="capex-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
- </label>
- <label style="margin-right: 15px;">OpEx Price:
- <select id="opex-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
- </label>
- <label style="margin-right: 15px;">Revenue Price:
- <select id="revenue-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
- </label>
- <label style="margin-right: 15px;">
- <input type="checkbox" id="include-ships"> Include Ship CapEx
- </label>
- <label style="margin-right: 15px;">
- <input type="number" id="working-capital" min="0" step="1" style="width: 50px;"> Days OpEx
- </label>
- <label>
- <input type="number" id="target-permit" min="1" step="1" style="width: 50px;"> Target Permit
- </label>
- `;
- 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('include-ships') as HTMLInputElement).checked = includeShips;
- (document.getElementById('working-capital') as HTMLInputElement).value = workingCapitalDays.toString();
- (document.getElementById('target-permit') as HTMLInputElement).value = targetPermit.toString();
- 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('include-ships')!.addEventListener('change', (e) => {
- includeShips = (e.target as HTMLInputElement).checked;
- render();
- });
- document.getElementById('working-capital')!.addEventListener('change', (e) => {
- workingCapitalDays = parseInt((e.target as HTMLInputElement).value, 10);
- render();
- });
- document.getElementById('target-permit')!.addEventListener('change', (e) => {
- targetPermit = parseInt((e.target as HTMLInputElement).value, 10);
- 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', 'logistics_per_base', 'market_capacity_base'
- ];
-
- 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.';
- } 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, working capital (days of OpEx), optional HQ Upgrade materials for the target permit, and optional Ship CapEx.';
- } 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.';
- } else if (keys[i] === '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.';
- } 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.';
- } else if (keys[i] === 'break_even') {
- th.dataset.tooltip = 'Click to sort.\n\nBreak Even: CapEx ÷ daily profit. Note that CapEx dynamically includes working capital and HQ upgrades to accurately reflect operational readiness.';
- } 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 = '<option value="">(all)</option>';
- 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;
- });
- const 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) + (opex_val * workingCapitalDays);
-
- // EXTREME DETAIL: We intercept the HQ permit cost here.
- // Permits 1 & 2 are free. To unlock permit 3, you must upgrade HQ to Level 2.
- // We map the user input directly to the JSON string keys retrieved from GitHub.
- let hq_capex = 0;
- 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;
- }
- }
- if (includeShips) {
- capex_val += p.ship_capex_per_base;
- }
- const profit_per_base = revenue_val - opex_val;
- const break_even = profit_per_base > 0 ? capex_val / profit_per_base : Infinity;
-
- return { ...p, capex_val, opex_val, revenue_val, profit_per_base, break_even, hq_capex };
- });
- 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');
-
- tr.innerHTML = `
- <td>${p.outputs.map(o => o.ticker).join(', ')}</td>
- <td>${expertise[p.expertise]}</td>
- <td style="color: ${color(p.profit_per_base, 0, 150000)}">${formatSigFig(p.profit_per_base)}</td>
- <td><span style="color: ${color(p.break_even, 30, 3)}">${formatSigFig(p.break_even)}</span>d</td>
- <td style="color: ${color(p.capex_val, 3_000_000, 400_000)}">${formatSigFig(p.capex_val)}</td>
- <td style="color: ${color(p.opex_val, 400_000, 10_000)}">${formatSigFig(p.opex_val)}</td>
- <td style="color: ${color(p.logistics_per_base, 1000, 100)}">${formatSigFig(p.logistics_per_base)} ${p.logistics_bottleneck}</td>
- <td style="color: ${color(p.market_capacity_base, 0.04, 1)}">${formatSigFig(p.market_capacity_base)}</td>
- `;
- 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))}`;
- capexCell.dataset.tooltip += `\nWorking Capital (${workingCapitalDays} days): ${formatSigFig(p.opex_val * workingCapitalDays)}`;
-
- // EXTREME DETAIL: Dynamically inject the HQ cost into the tooltip if the user requested Permit 3 or higher.
- if (p.hq_capex > 0) {
- capexCell.dataset.tooltip += `\nHQ Upgrade (Permit ${targetPermit}): ${formatSigFig(p.hq_capex)}`;
- }
- if (includeShips) {
- capexCell.dataset.tooltip += `\nShip CapEx: ${formatSigFig(p.ship_capex_per_base)} (${formatSigFig(p.ship_capex_per_base / 800_000)} 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();
- }
- 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-include-ships', includeShips.toString());
- localStorage.setItem('roi-working-capital', workingCapitalDays.toString());
- localStorage.setItem('roi-target-permit', targetPermit.toString());
- }
- 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');
- }
- setupPopover();
- lowVolume.addEventListener('change', render);
- cxSelect.addEventListener('change', render);
- expertiseSelect.addEventListener('change', render);
- buildingSelect.addEventListener('change', render);
- render();
- 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
- logistics_bottleneck: string
- output_per_day: number
- average_traded_7d: number
- market_capacity_base: number
- ship_capex_per_base: number
- hq_costs: Record<string, Metrics> // Added typing for the precalculated HQ pricing dictionary
- }
- 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; // Added to interface to pass to the tooltip generator
- }
- 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;
- }
|