import {cachedFetchJSON} from './cache'; import {setupPopover} from './popover'; let blueprint: Record; let BUY: Set; let renderTarget: Element; let cx: string; const apiKey = document.querySelector('#api-key') as HTMLInputElement; export function setupProduction(bp: Record, buy: Set, cxCode: string, target: Element) { blueprint = bp; BUY = buy; cx = cxCode; renderTarget = target; const storedApiKey = localStorage.getItem('punoted-api-key'); if (storedApiKey) apiKey.value = storedApiKey; document.querySelector('#fetch')!.addEventListener('click', render); setupPopover(); render(); } async function render() { const loader = document.querySelector('#loader') as HTMLElement; loader.style.display = 'block'; try { await _render(); if (apiKey.value) localStorage.setItem('punoted-api-key', apiKey.value); } catch (e) { renderTarget.innerHTML = e instanceof Error ? e.message : String(e); } loader.style.display = 'none'; } async function _render() { const [allPrices, recipes, buildingList, storage] = await Promise.all([ cachedFetchJSON('https://refined-prun.github.io/refined-prices/all.json') as Promise, recipeForMats(), cachedFetchJSON('https://api.prunplanner.org/data/buildings/') as Promise, fetchStorage(), ]); const prices = Object.fromEntries(allPrices.filter((price) => price.ExchangeCode === cx) .map((price) => [price.MaterialTicker, price])); const buildings = Object.fromEntries(buildingList.map((b) => [b.building_ticker, b])); const production: Production = {}; const extract: Record = {}; const buy: Record = {}; const analysisNodes: AnalysisNode[] = []; let cost = 0; for (const [mat, amount] of Object.entries(blueprint)) { const node = analyzeMat(mat, amount, production, extract, buy, prices, recipes, storage); cost += node.cost; analysisNodes.push(node); } // requiredMats = buy + production const requiredMats: Record = {...buy}; for (const buildingProduction of Object.values(production)) for (const [mat, amount] of Object.entries(buildingProduction)) requiredMats[mat] = (requiredMats[mat] ?? 0) + amount; const expertiseGroups: Record = {}; for (const building of buildingList) { if (!(building.building_ticker in production)) continue; if (!expertiseGroups[building.expertise]) expertiseGroups[building.expertise] = []; expertiseGroups[building.expertise].push(building.building_ticker); } renderTarget.innerHTML = ''; renderTarget.append( renderAnalysis(analysisNodes), element('p', {textContent: `total cost: ${formatWhole(cost)}`}), renderProduction(expertiseGroups, production, prices, recipes, buildings), renderMatList('extract', extract), renderMatList('buy', buy), ); } function renderMatList(header: string, mats: Record): HTMLElement { const section = element('section'); section.append(element('h2', {textContent: header})); const matsSorted = Object.entries(mats).sort(([a], [b]) => a.localeCompare(b)); section.append(element('p', { textContent: matsSorted.map(([mat, amount]) => `${formatAmount(amount)}x${mat}`).join(', '), })); return section; } async function recipeForMats(): Promise> { const allRecipes: Recipe[] = await cachedFetchJSON('https://api.prunplanner.org/data/recipes/'); const matRecipes: Record = {}; // all ways to make a mat for (const recipe of allRecipes) for (const output of recipe.outputs) { const recipes = matRecipes[output.material_ticker]; if (recipes) recipes.push(recipe); else matRecipes[output.material_ticker] = [recipe]; } const matRecipe: Record = {}; // mats for which there's only one recipe to make for (const [mat, recipes] of Object.entries(matRecipes)) if (recipes.length === 1) matRecipe[mat] = recipes[0]; return matRecipe; } function analyzeMat(mat: string, amount: number, production: Production, extract: Record, buy: Record, prices: Record, recipes: Record, storage: Record): AnalysisNode { const price = prices[mat]; if (!price) throw new Error(`missing price for ${mat}`); const traded = price.AverageTraded30D ?? 0; const inStorage = storage[mat] ?? 0; if (BUY.has(mat)) { const matPrice = price.VWAP30D ?? price.Ask; if (matPrice == null) throw new Error(`missing ask price for ${mat}`); buy[mat] = (buy[mat] ?? 0) + amount; return { ticker: mat, amount, inStorage, acquisition: `buy: ${formatAmount(matPrice)}, daily traded ${formatFixed(traded, 1)}`, children: [], cost: matPrice * amount, }; } const recipe = recipes[mat]; if (!recipe) { extract[mat] = (extract[mat] ?? 0) + amount; return {ticker: mat, amount, inStorage, acquisition: `extract`, children: [], cost: 0}; } const building = recipe.building_ticker; if (!production[building]) production[building] = {}; production[building][mat] = (production[building][mat] ?? 0) + amount; const liquid = traded > amount * 2 ? 'liquid' : 'not liquid'; let totalCost = 0; const children: AnalysisNode[] = []; for (const inputMat of recipe.inputs) { const inputAmount = inputMat.material_amount * amount / recipe.outputs[0].material_amount; const node = analyzeMat(inputMat.material_ticker, inputAmount, production, extract, buy, prices, recipes, storage); totalCost += node.cost; children.push(node); } return { ticker: mat, amount, inStorage, acquisition: `make (${building}, ${liquid})`, children, cost: totalCost, }; } function renderAnalysis(nodes: AnalysisNode[]): HTMLElement { const section = element('section'); for (const node of nodes) section.append(renderAnalysisNode(node)); return section; } function renderAnalysisNode(node: AnalysisNode, level = 0): HTMLElement { const amountText = element('span', {textContent: `${formatAmount(node.amount)}x${node.ticker} `}); const storageText = element('span', {textContent: `(${formatAmount(node.inStorage)})`}); const percent = Math.min(node.inStorage / node.amount, 1); storageText.style.color = `color-mix(in xyz, #0aa ${percent * 100}%, #f80 )`; const acquisitionText = element('span', {textContent: ' ' + node.acquisition}); let el; if (node.children.length === 0) { el = element('div', {className: 'analysis-node'}); el.append(amountText, storageText, acquisitionText); } else { el = element('details', {className: 'analysis-node', open: level > 0 && percent < 1}); const summary = element('summary'); summary.append(amountText, storageText, acquisitionText); el.append(summary); for (const child of node.children) el.append(renderAnalysisNode(child, level + 1)); el.append(document.createTextNode(`total cost: ${formatWhole(node.cost)}`)); } if (level === 0) el.classList.add('root'); return el; } const WORKER_CONSUMPTION: Record<'pioneers' | 'settlers' | 'technicians' | 'engineers' | 'scientists', Record> = { pioneers: {'COF': 0.5, 'DW': 4, 'RAT': 4, 'OVE': 0.5, 'PWO': 0.2}, settlers: {'DW': 5, 'RAT': 6, 'KOM': 1, 'EXO': 0.5, 'REP': 0.2, 'PT': 0.5}, technicians: {'DW': 7.5, 'RAT': 7, 'ALE': 1, 'MED': 0.5, 'SC': 0.1, 'HMS': 0.5, 'SCN': 0.1}, engineers: {'DW': 10, 'MED': 0.5, 'GIN': 1, 'FIM': 7, 'VG': 0.2, 'HSS': 0.2, 'PDA': 0.1}, scientists: {'DW': 10, 'MED': 0.5, 'WIN': 1, 'MEA': 7, 'NST': 0.1, 'LC': 0.2, 'WS': 0.05}, }; function buildingDailyCost(building: Building, prices: Record): number { let cost = 0; for (const [workerType, mats] of Object.entries(WORKER_CONSUMPTION)) { const workers = building[workerType as keyof typeof WORKER_CONSUMPTION]; for (const [mat, per100] of Object.entries(mats)) { const price = prices[mat].VWAP30D; if (price == null) throw new Error(`no price for ${mat}`); cost += price * workers * per100 / 100; } } return cost; } function renderProduction(expertiseGroups: Record, production: Production, prices: Record, recipes: Record, buildings: Record): HTMLElement { const section = element('section'); section.append(element('h2', {textContent: 'production'})); // mat → list of {outputMat, expertise, amount} that consume it as an input const matConsumers: Record = {}; for (const [expertise, productionBuildings] of Object.entries(expertiseGroups)) { for (const building of productionBuildings) { for (const [mat, totalAmount] of Object.entries(production[building])) { const recipe = recipes[mat]; const outputPerRun = recipe.outputs.find((o) => o.material_ticker === mat)!.material_amount; for (const input of recipe.inputs) { const ticker = input.material_ticker; if (!matConsumers[ticker]) matConsumers[ticker] = []; const amount = input.material_amount * totalAmount / outputPerRun; matConsumers[ticker].push({downstreamMat: mat, expertise, amount}); } } } } let totalConsumablesCost = 0; for (const [expertise, productionBuildings] of Object.entries(expertiseGroups)) { section.append(element('h3', {textContent: expertise.toLocaleLowerCase()})); const shipTo: Record> = {}; for (const building of productionBuildings) { const buildingMats = element('div'); const mats = Object.entries(production[building]); let buildingMins = 0; for (const [index, [mat, amount]] of mats.entries()) { const traded = prices[mat]?.AverageTraded30D ?? 0; const span = element('span', {textContent: `${formatAmount(amount)}x${mat}`}); span.style.color = traded > amount * 2 ? '#0cc' : '#c70'; const consumers = matConsumers[mat]; if (consumers) { span.dataset.tooltip = consumers .map((c) => `${formatAmount(c.amount)}x${mat} → ${c.downstreamMat} (${c.expertise.toLocaleLowerCase()})`) .join('\n'); for (const consumer of consumers) { if (consumer.expertise == expertise) // we aren't shipping it anywhere continue; if (!shipTo[consumer.expertise]) shipTo[consumer.expertise] = {}; shipTo[consumer.expertise][mat] = (shipTo[consumer.expertise][mat] ?? 0) + consumer.amount; } } buildingMats.append(span); if (index < mats.length - 1) buildingMats.append(document.createTextNode(' ')); const recipe = recipes[mat]; const outputPerRun = recipe.outputs.find((o) => o.material_ticker === mat)!.material_amount; buildingMins += amount / outputPerRun * (recipe.time_ms / 1000 / 60); } const numBuildings = buildingMins / (24*60) / 5 / 1.605; // one ship every 5 days, 160.5% efficiency const consumablesCost = buildingDailyCost(buildings[building], prices) * Math.round(Math.max(1, numBuildings)); totalConsumablesCost += consumablesCost; const buildingRow = element('div', {className: 'building-row', textContent: `${formatFixed(numBuildings, 1)}x${building} (${formatWhole(consumablesCost)}/d)`}); buildingRow.append(buildingMats); section.append(buildingRow); } const shipToDetails = element('details'); shipToDetails.append(element('summary', {textContent: 'ship to'})); for (const [expertise, mats] of Object.entries(shipTo)) { const shipToRow = element('div', {textContent: expertise.toLocaleLowerCase() + ': '}); shipToRow.textContent += Object.entries(mats).map(([mat, amount]) => `${amount}x${mat}`).join(' '); shipToDetails.append(shipToRow); } section.append(shipToDetails); } section.append(element('h4', {textContent: `total consumables cost: ${formatWhole(totalConsumablesCost)}/day, ${formatWhole(totalConsumablesCost * 5)}/ship`})); return section; } async function fetchStorage(): Promise> { if (!apiKey.value) return {}; const users = await fetch('https://api.punoted.net/v1/storages/', {headers: {'X-Data-Token': apiKey.value}}) .then((r) => r.json()) as PUNUserStore[]; const items: Record = {}; for (const user of users) for (const storage of user.Storages) for (const item of storage.StorageItems) items[item.MaterialTicker] = (items[item.MaterialTicker] ?? 0) + item.MaterialAmount; return items; } function element(tagName: K, properties: Partial = {}): HTMLElementTagNameMap[K] { const node = document.createElement(tagName); Object.assign(node, properties); return node; } function formatAmount(n: number): string { return n.toLocaleString(undefined, {maximumFractionDigits: 3}); } function formatFixed(n: number, digits: number): string { return n.toLocaleString(undefined, { minimumFractionDigits: digits, maximumFractionDigits: digits, }); } function formatWhole(n: number): string { return n.toLocaleString(undefined, {maximumFractionDigits: 0}); } interface AnalysisNode { ticker: string amount: number inStorage: number acquisition: string children: AnalysisNode[] cost: number } interface Recipe { recipe_name: string building_ticker: string inputs: RecipeMat[] outputs: RecipeMat[] time_ms: number } interface RecipeMat { material_ticker: string material_amount: number } interface RawPrice { MaterialTicker: string ExchangeCode: string Ask: number | null AverageTraded30D: number | null VWAP30D: number | null Supply: number } interface Building { building_ticker: string expertise: string pioneers: number settlers: number technicians: number engineers: number scientists: number } interface PUNUserStore { Storages: Array<{ StorageItems: Array<{ MaterialTicker: string MaterialAmount: number }> }> } type Production = Record>;