production.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. // ts/cache.ts
  2. var fetchCache = new Map;
  3. async function cachedFetchJSON(url, options) {
  4. if (fetchCache.has(url))
  5. return fetchCache.get(url);
  6. const response = await fetch(url, options).then((r) => r.json());
  7. fetchCache.set(url, response);
  8. return response;
  9. }
  10. // ts/popover.ts
  11. function setupPopover() {
  12. const main = document.querySelector("main");
  13. const popover = document.querySelector("#popover");
  14. main.addEventListener("mouseover", (event) => {
  15. const target = event.target;
  16. if (target.dataset.tooltip) {
  17. popover.textContent = target.dataset.tooltip;
  18. const rect = target.getBoundingClientRect();
  19. popover.style.left = `${rect.left}px`;
  20. popover.style.top = `${rect.bottom}px`;
  21. popover.showPopover();
  22. }
  23. });
  24. main.addEventListener("mouseout", (event) => {
  25. const target = event.target;
  26. if (target.dataset.tooltip)
  27. popover.hidePopover();
  28. });
  29. }
  30. // ts/production.ts
  31. var blueprint;
  32. var BUY;
  33. var renderTarget;
  34. var cx;
  35. var daysPerBundle;
  36. var apiKey = document.querySelector("#api-key");
  37. function setupProduction(bp, buy, cxCode, days, target) {
  38. blueprint = bp;
  39. BUY = buy;
  40. cx = cxCode;
  41. daysPerBundle = days;
  42. renderTarget = target;
  43. const storedApiKey = localStorage.getItem("punoted-api-key");
  44. if (storedApiKey)
  45. apiKey.value = storedApiKey;
  46. document.querySelector("#fetch").addEventListener("click", render);
  47. setupPopover();
  48. render();
  49. }
  50. async function render() {
  51. const loader = document.querySelector("#loader");
  52. loader.style.display = "block";
  53. try {
  54. await _render();
  55. if (apiKey.value)
  56. localStorage.setItem("punoted-api-key", apiKey.value);
  57. } catch (e) {
  58. renderTarget.innerHTML = e instanceof Error ? e.message : String(e);
  59. }
  60. loader.style.display = "none";
  61. }
  62. async function _render() {
  63. const [allPrices, { recipes, extractables }, buildingList, storage] = await Promise.all([
  64. cachedFetchJSON("https://refined-prun.github.io/refined-prices/all.json"),
  65. recipeForMats(),
  66. cachedFetchJSON("https://api.prunplanner.org/data/buildings/"),
  67. fetchStorage()
  68. ]);
  69. const prices = Object.fromEntries(allPrices.filter((price) => price.ExchangeCode === cx).map((price) => [price.MaterialTicker, price]));
  70. const buildings = Object.fromEntries(buildingList.map((b) => [b.building_ticker, b]));
  71. const production = {};
  72. const extract = {};
  73. const buy = {};
  74. const analysisNodes = [];
  75. let cost = 0;
  76. for (const [mat, amount] of Object.entries(blueprint)) {
  77. const node = analyzeMat(mat, amount, production, extract, buy, prices, recipes, extractables, storage);
  78. cost += node.cost;
  79. analysisNodes.push(node);
  80. }
  81. const requiredMats = { ...buy };
  82. for (const buildingProduction of Object.values(production))
  83. for (const [mat, amount] of Object.entries(buildingProduction))
  84. requiredMats[mat] = (requiredMats[mat] ?? 0) + amount;
  85. const expertiseGroups = {};
  86. for (const building of buildingList) {
  87. if (!(building.building_ticker in production))
  88. continue;
  89. if (!expertiseGroups[building.expertise])
  90. expertiseGroups[building.expertise] = [];
  91. expertiseGroups[building.expertise].push(building.building_ticker);
  92. }
  93. renderTarget.innerHTML = "";
  94. 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));
  95. }
  96. function renderMatList(header, mats, storage) {
  97. const section = element("section");
  98. section.append(element("h2", { textContent: header }));
  99. const matsSorted = Object.entries(mats).sort(([a], [b]) => a.localeCompare(b));
  100. for (const [mat, amount] of matsSorted) {
  101. const div = element("div", { textContent: `${formatAmount(amount)}x${mat}` });
  102. const storageText = element("span", { textContent: ` (${formatAmount(storage[mat] ?? 0)})` });
  103. const percent = Math.min((storage[mat] ?? 0) / (amount * 2), 1);
  104. storageText.style.color = `color-mix(in xyz, #0aa ${percent * 100}%, #f80 )`;
  105. div.append(storageText);
  106. section.append(div);
  107. }
  108. return section;
  109. }
  110. async function recipeForMats() {
  111. const [allRecipes, materials] = await Promise.all([
  112. cachedFetchJSON("https://api.prunplanner.org/data/recipes/"),
  113. cachedFetchJSON("https://api.prunplanner.org/data/materials/")
  114. ]);
  115. const extractables = new Set(materials.filter((m) => ["ores", "minerals", "liquids", "gases"].includes(m.category_name)).map((m) => m.ticker));
  116. const chosenRecipes = {
  117. AL: "6xALO 1xO 1xC 1xFLX=>4xAL",
  118. C: "4xHCP=>4xC",
  119. DRF: "50xNFI 1xDCS=>1xDRF",
  120. FE: "6xFEO 1xC 1xO 1xFLX=>4xFE",
  121. GL: "2xSIO 1xNA 1xSEN=>10xGL",
  122. HCP: "14xH2O 1xNS=>8xHCP",
  123. RE: "8xREO 1xC 1xO 1xFLX=>5xRE",
  124. RG: "10xGL 15xPG 1xSEN=>10xRG",
  125. SI: "3xSIO 1xC 1xO 1xFLX=>1xSI"
  126. };
  127. const matRecipes = {};
  128. for (const recipe of allRecipes)
  129. for (const output of recipe.outputs) {
  130. if (extractables.has(output.material_ticker))
  131. continue;
  132. if (chosenRecipes[output.material_ticker] && recipe.recipe_name != chosenRecipes[output.material_ticker])
  133. continue;
  134. const recipes = matRecipes[output.material_ticker];
  135. if (recipes)
  136. recipes.push(recipe);
  137. else
  138. matRecipes[output.material_ticker] = [recipe];
  139. }
  140. const matRecipe = {};
  141. for (const [mat, recipes] of Object.entries(matRecipes))
  142. if (recipes.length === 1)
  143. matRecipe[mat] = recipes[0];
  144. return { recipes: matRecipe, extractables };
  145. }
  146. function analyzeMat(mat, amount, production, extract, buy, prices, recipes, extractables, storage) {
  147. const price = prices[mat];
  148. if (!price)
  149. throw new Error(`missing price for ${mat}`);
  150. const traded = price.AverageTraded30D ?? 0;
  151. const inStorage = storage[mat] ?? 0;
  152. if (BUY.has(mat)) {
  153. const matPrice = price.VWAP30D ?? price.Ask;
  154. if (matPrice == null)
  155. throw new Error(`missing ask price for ${mat}`);
  156. buy[mat] = (buy[mat] ?? 0) + amount;
  157. return {
  158. ticker: mat,
  159. amount,
  160. inStorage,
  161. acquisition: `buy: ${formatAmount(matPrice)}, daily traded ${formatFixed(traded, 1)}`,
  162. children: [],
  163. cost: matPrice * amount
  164. };
  165. }
  166. if (extractables.has(mat)) {
  167. extract[mat] = (extract[mat] ?? 0) + amount;
  168. return { ticker: mat, amount, inStorage, acquisition: `extract`, children: [], cost: 0 };
  169. }
  170. const recipe = recipes[mat];
  171. if (!recipe)
  172. throw new Error(`no recipe for ${mat}`);
  173. const building = recipe.building_ticker;
  174. if (!production[building])
  175. production[building] = {};
  176. production[building][mat] = (production[building][mat] ?? 0) + amount;
  177. const liquid = traded > amount * 2 ? "liquid" : "not liquid";
  178. let totalCost = 0;
  179. const children = [];
  180. for (const inputMat of recipe.inputs) {
  181. const inputAmount = inputMat.material_amount * amount / recipe.outputs[0].material_amount;
  182. const node = analyzeMat(inputMat.material_ticker, inputAmount, production, extract, buy, prices, recipes, extractables, storage);
  183. totalCost += node.cost;
  184. children.push(node);
  185. }
  186. return {
  187. ticker: mat,
  188. amount,
  189. inStorage,
  190. acquisition: `make (${building}, ${liquid})`,
  191. children,
  192. cost: totalCost
  193. };
  194. }
  195. function renderAnalysis(nodes) {
  196. const section = element("section");
  197. for (const node of nodes)
  198. section.append(renderAnalysisNode(node));
  199. return section;
  200. }
  201. function renderAnalysisNode(node, level = 0) {
  202. const amountText = element("span", { textContent: `${formatAmount(node.amount)}x${node.ticker} ` });
  203. const storageText = element("span", { textContent: `(${formatAmount(node.inStorage)})` });
  204. const percent = Math.min(node.inStorage / node.amount, 1);
  205. storageText.style.color = `color-mix(in xyz, #0aa ${percent * 100}%, #f80 )`;
  206. const acquisitionText = element("span", { textContent: " " + node.acquisition });
  207. let el;
  208. if (node.children.length === 0) {
  209. el = element("div", { className: "analysis-node" });
  210. el.append(amountText, storageText, acquisitionText);
  211. } else {
  212. el = element("details", { className: "analysis-node", open: level > 0 && percent < 1 });
  213. const summary = element("summary");
  214. summary.append(amountText, storageText, acquisitionText);
  215. el.append(summary);
  216. for (const child of node.children)
  217. el.append(renderAnalysisNode(child, level + 1));
  218. el.append(document.createTextNode(`total cost: ${formatWhole(node.cost)}`));
  219. }
  220. if (level === 0)
  221. el.classList.add("root");
  222. return el;
  223. }
  224. var WORKER_CONSUMPTION = {
  225. pioneers: { COF: 0.5, DW: 4, RAT: 4, OVE: 0.5, PWO: 0.2 },
  226. settlers: { DW: 5, RAT: 6, KOM: 1, EXO: 0.5, REP: 0.2, PT: 0.5 },
  227. technicians: { DW: 7.5, RAT: 7, ALE: 1, MED: 0.5, SC: 0.1, HMS: 0.5, SCN: 0.1 },
  228. engineers: { DW: 10, MED: 0.5, GIN: 1, FIM: 7, VG: 0.2, HSS: 0.2, PDA: 0.1 },
  229. scientists: { DW: 10, MED: 0.5, WIN: 1, MEA: 7, NST: 0.1, LC: 0.2, WS: 0.05 }
  230. };
  231. function buildingDailyCost(building, prices) {
  232. let cost = 0;
  233. for (const [workerType, mats] of Object.entries(WORKER_CONSUMPTION)) {
  234. const workers = building[workerType];
  235. for (const [mat, per100] of Object.entries(mats)) {
  236. const price = prices[mat].VWAP30D;
  237. if (price == null)
  238. throw new Error(`no price for ${mat}`);
  239. cost += price * workers * per100 / 100;
  240. }
  241. }
  242. return cost;
  243. }
  244. function renderProduction(expertiseGroups, production, storage, prices, recipes, buildings) {
  245. const section = element("section");
  246. section.append(element("h2", { textContent: "production" }));
  247. const matInputs = {};
  248. const matConsumers = {};
  249. for (const [expertise, productionBuildings] of Object.entries(expertiseGroups)) {
  250. for (const building of productionBuildings) {
  251. for (const [mat, totalAmount] of Object.entries(production[building])) {
  252. const recipe = recipes[mat];
  253. if (!matInputs[mat])
  254. matInputs[mat] = [];
  255. const outputPerRun = recipe.outputs.find((o) => o.material_ticker === mat).material_amount;
  256. for (const input of recipe.inputs) {
  257. const ticker = input.material_ticker;
  258. const amount = input.material_amount * totalAmount / outputPerRun;
  259. matInputs[mat].push({ upstreamMat: ticker, amount });
  260. if (!matConsumers[ticker])
  261. matConsumers[ticker] = [];
  262. matConsumers[ticker].push({ downstreamMat: mat, expertise, amount });
  263. }
  264. }
  265. }
  266. }
  267. let totalConsumablesCost = 0;
  268. for (const [expertise, productionBuildings] of Object.entries(expertiseGroups)) {
  269. section.append(element("h3", { textContent: expertise.toLocaleLowerCase() }));
  270. const imports = {};
  271. const exportTo = {};
  272. for (const building of productionBuildings) {
  273. const buildingRow = element("div", { className: "building-row" });
  274. const mats = Object.entries(production[building]);
  275. let buildingMins = 0;
  276. for (const [mat, amount] of mats) {
  277. buildingRow.append(document.createTextNode(" "));
  278. buildingRow.append(renderProductionBuildingMat(expertise, mat, amount, storage, matInputs, matConsumers, imports, exportTo));
  279. const recipe = recipes[mat];
  280. const outputPerRun = recipe.outputs.find((o) => o.material_ticker === mat).material_amount;
  281. buildingMins += amount / outputPerRun * (recipe.time_ms / 1000 / 60);
  282. }
  283. const numBuildings = buildingMins / (24 * 60) / daysPerBundle / 1.605;
  284. const consumablesCost = buildingDailyCost(buildings[building], prices) * Math.round(Math.max(1, numBuildings));
  285. totalConsumablesCost += consumablesCost;
  286. buildingRow.prepend(document.createTextNode(`${formatFixed(numBuildings, 1)}x${building} (${formatWhole(consumablesCost)}/d)`));
  287. section.append(buildingRow);
  288. }
  289. const importDetails = element("details");
  290. importDetails.append(element("summary", { textContent: "imports" }));
  291. for (const [mat, amount] of Object.entries(imports)) {
  292. if (recipes[mat] && buildings[recipes[mat].building_ticker].expertise == expertise)
  293. continue;
  294. importDetails.append(element("div", { textContent: `${formatAmount(amount)}x${mat}` }));
  295. }
  296. section.append(importDetails);
  297. const exportDetails = element("details");
  298. exportDetails.append(element("summary", { textContent: "exports" }));
  299. for (const [expertise2, mats] of Object.entries(exportTo)) {
  300. const exportToRow = element("div", { textContent: expertise2.toLocaleLowerCase() + ": " });
  301. exportToRow.textContent += Object.entries(mats).map(([mat, amount]) => `${amount}x${mat}`).join(" ");
  302. exportDetails.append(exportToRow);
  303. }
  304. section.append(exportDetails);
  305. }
  306. section.append(element("h4", { textContent: `total consumables cost: ${formatWhole(totalConsumablesCost)}/day,
  307. ${formatWhole(totalConsumablesCost * daysPerBundle)}/bundle` }));
  308. return section;
  309. }
  310. function renderProductionBuildingMat(expertise, mat, amount, storage, matInputs, matConsumers, imports, exportTo) {
  311. const inStorage = storage[mat] ?? 0;
  312. const wrapper = element("span");
  313. const amountText = element("span", { textContent: `${formatAmount(amount)}x${mat}` });
  314. wrapper.append(amountText);
  315. const storageText = element("span", { textContent: ` (${formatAmount(inStorage)})` });
  316. wrapper.append(storageText);
  317. const percent = Math.min(inStorage / (amount * 2), 1);
  318. storageText.style.color = `color-mix(in xyz, #0aa ${percent * 100}%, #f80 )`;
  319. if (percent >= 1)
  320. amountText.style.color = "#777";
  321. else {
  322. const produceable = Object.values(matInputs[mat]).every((input) => storage[input.upstreamMat] ?? 0 >= input.amount);
  323. amountText.style.color = produceable ? "#0aa" : "#f80";
  324. }
  325. let tooltip = "";
  326. const inputs = matInputs[mat];
  327. if (inputs) {
  328. tooltip += inputs.map((i) => `${formatAmount(i.amount)}x${i.upstreamMat} (${storage[i.upstreamMat] ?? 0}) → ` + `${formatAmount(amount)}x${mat}`).join(`
  329. `) + `
  330. `;
  331. for (const input of inputs)
  332. imports[input.upstreamMat] = (imports[input.upstreamMat] ?? 0) + input.amount;
  333. }
  334. const consumers = matConsumers[mat];
  335. if (consumers) {
  336. tooltip += consumers.map((c) => `${formatAmount(c.amount)}x${mat} → ${c.downstreamMat} (${c.expertise.toLocaleLowerCase()})`).join(`
  337. `);
  338. for (const consumer of consumers) {
  339. if (consumer.expertise == expertise)
  340. continue;
  341. if (!exportTo[consumer.expertise])
  342. exportTo[consumer.expertise] = {};
  343. exportTo[consumer.expertise][mat] = (exportTo[consumer.expertise][mat] ?? 0) + consumer.amount;
  344. }
  345. }
  346. amountText.dataset.tooltip = tooltip;
  347. return wrapper;
  348. }
  349. async function fetchStorage() {
  350. if (!apiKey.value)
  351. return {};
  352. const users = await fetch("https://api.punoted.net/v1/storages/", { headers: { "X-Data-Token": apiKey.value } }).then((r) => r.json());
  353. const items = {};
  354. for (const user of users)
  355. for (const storage of user.Storages)
  356. for (const item of storage.StorageItems)
  357. items[item.MaterialTicker] = (items[item.MaterialTicker] ?? 0) + item.MaterialAmount;
  358. return items;
  359. }
  360. function element(tagName, properties = {}) {
  361. const node = document.createElement(tagName);
  362. Object.assign(node, properties);
  363. return node;
  364. }
  365. function formatAmount(n) {
  366. return n.toLocaleString(undefined, { maximumFractionDigits: 3 });
  367. }
  368. function formatFixed(n, digits) {
  369. return n.toLocaleString(undefined, {
  370. minimumFractionDigits: digits,
  371. maximumFractionDigits: digits
  372. });
  373. }
  374. function formatWhole(n) {
  375. return n.toLocaleString(undefined, { maximumFractionDigits: 0 });
  376. }
  377. export {
  378. setupProduction
  379. };
  380. //# debugId=02424DECF53B716064756E2164756E21