// ts/cache.ts var fetchCache = new Map; async function cachedFetchJSON(url, options) { if (fetchCache.has(url)) return fetchCache.get(url); const response = await fetch(url, options).then((r) => r.json()); fetchCache.set(url, response); return response; } // ts/popover.ts function setupPopover() { const main = document.querySelector("main"); const popover = document.querySelector("#popover"); main.addEventListener("mouseover", (event) => { const target = event.target; if (target.dataset.tooltip) { popover.textContent = target.dataset.tooltip; const rect = target.getBoundingClientRect(); popover.style.left = `${rect.left}px`; popover.style.top = `${rect.bottom}px`; popover.showPopover(); } }); main.addEventListener("mouseout", (event) => { const target = event.target; if (target.dataset.tooltip) popover.hidePopover(); }); } // ts/production.ts var blueprint; var BUY; var renderTarget; var cx; var daysPerBundle; var apiKey = document.querySelector("#api-key"); function setupProduction(bp, buy, cxCode, days, target) { blueprint = bp; BUY = buy; cx = cxCode; daysPerBundle = days; 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"); 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, extractables }, buildingList, storage] = await Promise.all([ cachedFetchJSON("https://refined-prun.github.io/refined-prices/all.json"), recipeForMats(), cachedFetchJSON("https://api.prunplanner.org/data/buildings/"), 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 = {}; const extract = {}; const buy = {}; const analysisNodes = []; let cost = 0; for (const [mat, amount] of Object.entries(blueprint)) { const node = analyzeMat(mat, amount, production, extract, buy, prices, recipes, extractables, storage); cost += node.cost; analysisNodes.push(node); } const requiredMats = { ...buy }; for (const buildingProduction of Object.values(production)) for (const [mat, amount] of Object.entries(buildingProduction)) requiredMats[mat] = (requiredMats[mat] ?? 0) + amount; const expertiseGroups = {}; 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, storage, prices, recipes, buildings), renderMatList("extract", extract, storage), renderMatList("buy", buy, storage)); } function renderMatList(header, mats, storage) { const section = element("section"); section.append(element("h2", { textContent: header })); const matsSorted = Object.entries(mats).sort(([a], [b]) => a.localeCompare(b)); for (const [mat, amount] of matsSorted) { const div = element("div", { textContent: `${formatAmount(amount)}x${mat}` }); const storageText = element("span", { textContent: ` (${formatAmount(storage[mat] ?? 0)})` }); const percent = Math.min((storage[mat] ?? 0) / (amount * 2), 1); storageText.style.color = `color-mix(in xyz, #0aa ${percent * 100}%, #f80 )`; div.append(storageText); section.append(div); } return section; } async function recipeForMats() { const [allRecipes, materials] = await Promise.all([ cachedFetchJSON("https://api.prunplanner.org/data/recipes/"), cachedFetchJSON("https://api.prunplanner.org/data/materials/") ]); const extractables = new Set(materials.filter((m) => ["ores", "minerals", "liquids", "gases"].includes(m.category_name)).map((m) => m.ticker)); const chosenRecipes = { AL: "6xALO 1xO 1xC 1xFLX=>4xAL", C: "4xHCP=>4xC", DRF: "50xNFI 1xDCS=>1xDRF", FE: "6xFEO 1xC 1xO 1xFLX=>4xFE", GL: "2xSIO 1xNA 1xSEN=>10xGL", HCP: "14xH2O 1xNS=>8xHCP", RE: "8xREO 1xC 1xO 1xFLX=>5xRE", RG: "10xGL 15xPG 1xSEN=>10xRG", SI: "3xSIO 1xC 1xO 1xFLX=>1xSI" }; const matRecipes = {}; for (const recipe of allRecipes) for (const output of recipe.outputs) { if (extractables.has(output.material_ticker)) continue; if (chosenRecipes[output.material_ticker] && recipe.recipe_name != chosenRecipes[output.material_ticker]) continue; const recipes = matRecipes[output.material_ticker]; if (recipes) recipes.push(recipe); else matRecipes[output.material_ticker] = [recipe]; } const matRecipe = {}; for (const [mat, recipes] of Object.entries(matRecipes)) if (recipes.length === 1) matRecipe[mat] = recipes[0]; return { recipes: matRecipe, extractables }; } function analyzeMat(mat, amount, production, extract, buy, prices, recipes, extractables, storage) { 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 }; } if (extractables.has(mat)) { extract[mat] = (extract[mat] ?? 0) + amount; return { ticker: mat, amount, inStorage, acquisition: `extract`, children: [], cost: 0 }; } const recipe = recipes[mat]; if (!recipe) throw new Error(`no recipe for ${mat}`); 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 = []; 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, extractables, storage); totalCost += node.cost; children.push(node); } return { ticker: mat, amount, inStorage, acquisition: `make (${building}, ${liquid})`, children, cost: totalCost }; } function renderAnalysis(nodes) { const section = element("section"); for (const node of nodes) section.append(renderAnalysisNode(node)); return section; } function renderAnalysisNode(node, level = 0) { 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; } var WORKER_CONSUMPTION = { 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, prices) { let cost = 0; for (const [workerType, mats] of Object.entries(WORKER_CONSUMPTION)) { const workers = building[workerType]; 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, production, storage, prices, recipes, buildings) { const section = element("section"); section.append(element("h2", { textContent: "production" })); const matInputs = {}; const matConsumers = {}; 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]; if (!matInputs[mat]) matInputs[mat] = []; const outputPerRun = recipe.outputs.find((o) => o.material_ticker === mat).material_amount; for (const input of recipe.inputs) { const ticker = input.material_ticker; const amount = input.material_amount * totalAmount / outputPerRun; matInputs[mat].push({ upstreamMat: ticker, amount }); if (!matConsumers[ticker]) matConsumers[ticker] = []; 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 imports = {}; const exportTo = {}; for (const building of productionBuildings) { const buildingRow = element("div", { className: "building-row" }); const mats = Object.entries(production[building]); let buildingMins = 0; for (const [mat, amount] of mats) { buildingRow.append(document.createTextNode(" ")); buildingRow.append(renderProductionBuildingMat(expertise, mat, amount, storage, matInputs, matConsumers, imports, exportTo)); 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) / daysPerBundle / 1.605; const consumablesCost = buildingDailyCost(buildings[building], prices) * Math.round(Math.max(1, numBuildings)); totalConsumablesCost += consumablesCost; buildingRow.prepend(document.createTextNode(`${formatFixed(numBuildings, 1)}x${building} (${formatWhole(consumablesCost)}/d)`)); section.append(buildingRow); } const importDetails = element("details"); importDetails.append(element("summary", { textContent: "imports" })); for (const [mat, amount] of Object.entries(imports)) { if (recipes[mat] && buildings[recipes[mat].building_ticker].expertise == expertise) continue; importDetails.append(element("div", { textContent: `${formatAmount(amount)}x${mat}` })); } section.append(importDetails); const exportDetails = element("details"); exportDetails.append(element("summary", { textContent: "exports" })); for (const [expertise2, mats] of Object.entries(exportTo)) { const exportToRow = element("div", { textContent: expertise2.toLocaleLowerCase() + ": " }); exportToRow.textContent += Object.entries(mats).map(([mat, amount]) => `${amount}x${mat}`).join(" "); exportDetails.append(exportToRow); } section.append(exportDetails); } section.append(element("h4", { textContent: `total consumables cost: ${formatWhole(totalConsumablesCost)}/day, ${formatWhole(totalConsumablesCost * daysPerBundle)}/bundle` })); return section; } function renderProductionBuildingMat(expertise, mat, amount, storage, matInputs, matConsumers, imports, exportTo) { const inStorage = storage[mat] ?? 0; const wrapper = element("span"); const amountText = element("span", { textContent: `${formatAmount(amount)}x${mat}` }); wrapper.append(amountText); const storageText = element("span", { textContent: ` (${formatAmount(inStorage)})` }); wrapper.append(storageText); const percent = Math.min(inStorage / (amount * 2), 1); storageText.style.color = `color-mix(in xyz, #0aa ${percent * 100}%, #f80 )`; if (percent >= 1) amountText.style.color = "#777"; else { const produceable = Object.values(matInputs[mat]).every((input) => storage[input.upstreamMat] ?? 0 >= input.amount); amountText.style.color = produceable ? "#0aa" : "#f80"; } let tooltip = ""; const inputs = matInputs[mat]; if (inputs) { tooltip += inputs.map((i) => `${formatAmount(i.amount)}x${i.upstreamMat} (${storage[i.upstreamMat] ?? 0}) → ` + `${formatAmount(amount)}x${mat}`).join(` `) + ` `; for (const input of inputs) imports[input.upstreamMat] = (imports[input.upstreamMat] ?? 0) + input.amount; } const consumers = matConsumers[mat]; if (consumers) { tooltip += consumers.map((c) => `${formatAmount(c.amount)}x${mat} → ${c.downstreamMat} (${c.expertise.toLocaleLowerCase()})`).join(` `); for (const consumer of consumers) { if (consumer.expertise == expertise) continue; if (!exportTo[consumer.expertise]) exportTo[consumer.expertise] = {}; exportTo[consumer.expertise][mat] = (exportTo[consumer.expertise][mat] ?? 0) + consumer.amount; } } amountText.dataset.tooltip = tooltip; return wrapper; } async function fetchStorage() { if (!apiKey.value) return {}; const users = await fetch("https://api.punoted.net/v1/storages/", { headers: { "X-Data-Token": apiKey.value } }).then((r) => r.json()); const items = {}; 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, properties = {}) { const node = document.createElement(tagName); Object.assign(node, properties); return node; } function formatAmount(n) { return n.toLocaleString(undefined, { maximumFractionDigits: 3 }); } function formatFixed(n, digits) { return n.toLocaleString(undefined, { minimumFractionDigits: digits, maximumFractionDigits: digits }); } function formatWhole(n) { return n.toLocaleString(undefined, { maximumFractionDigits: 0 }); } export { setupProduction }; //# debugId=02424DECF53B716064756E2164756E21