plan.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. import {cachedFetchJSON} from './cache';
  2. import {Counter} from './counter';
  3. const renderTarget = document.querySelector('#plan')!;
  4. const shareURL = document.querySelector('#share_url') as HTMLInputElement;
  5. const cxSelect = document.querySelector('#cx') as HTMLSelectElement;
  6. function serializeToHash(shared: string, cx: string): void {
  7. const params = new URLSearchParams({shared, cx});
  8. document.location.hash = params.toString();
  9. }
  10. function deserializeFromHash(): {shareUUID: string, cx: string} | null {
  11. const params = new URLSearchParams(document.location.hash.substring(1));
  12. const shareUUID = params.get('shared');
  13. const cx = params.get('cx');
  14. if (shareUUID === null || cx === null)
  15. return null;
  16. shareURL.value = 'https://prunplanner.org/shared/' + shareUUID;
  17. cxSelect.value = cx;
  18. return {shareUUID, cx};
  19. }
  20. document.querySelector('form')!.addEventListener('submit', async (event) => {
  21. event.preventDefault();
  22. let shareUUID = shareURL.value;
  23. if (shareUUID) {
  24. if (shareUUID.startsWith('https://prunplanner.org/shared/'))
  25. shareUUID = shareUUID.substring('https://prunplanner.org/shared/'.length);
  26. const cx = cxSelect.value;
  27. await render(shareUUID, cx);
  28. serializeToHash(shareUUID, cx);
  29. }
  30. });
  31. {
  32. const hash = deserializeFromHash();
  33. if (hash !== null)
  34. void render(hash.shareUUID, hash.cx);
  35. }
  36. async function render(shareUUID: string, cx: string) {
  37. const loader = document.querySelector('#loader') as HTMLElement;
  38. loader.style.display = 'block';
  39. renderTarget.innerHTML = '';
  40. try {
  41. await _render(shareUUID, cx);
  42. } catch (e) {
  43. console.error(e);
  44. renderTarget.textContent = (e as Error).message;
  45. }
  46. loader.style.display = 'none';
  47. }
  48. async function _render(shareUUID: string, cx: string) {
  49. const [plan, buildings, recipeList, exchanges, materialList, graph]:
  50. [Plan, Building[], Recipe[], Exchange[], Material[], Graph] = await Promise.all([
  51. cachedFetchJSON('https://api.prunplanner.org/planning/shared/' + shareUUID),
  52. cachedFetchJSON('https://api.prunplanner.org/data/buildings/'),
  53. cachedFetchJSON('https://api.prunplanner.org/data/recipes/'),
  54. cachedFetchJSON('https://api.prunplanner.org/data/exchanges/'),
  55. cachedFetchJSON('https://api.prunplanner.org/data/materials/'),
  56. cachedFetchJSON('/graph_data.json'),
  57. ]);
  58. const planet: Planet = await cachedFetchJSON(`https://api.prunplanner.org/data/planet/${plan.plan_details.planet_natural_id}/`);
  59. const planExperts = new Map<Expertise, number>(plan.plan_details.plan_data.experts.map(
  60. e => [e.type.toUpperCase() as Expertise, e.amount]));
  61. const buildingExpertise = new Map<string, Expertise>(buildings.map(b => [b.building_ticker, b.expertise]));
  62. const recipes = new Map<string, Recipe>(recipeList.map(r => [r.recipe_id, r]));
  63. const dailyTraded = new Map<string, number>(exchanges.filter((ex) => ex.exchange_code === cx)
  64. .map((ex) => [ex.ticker, ex.sum_traded_7d / 7]));
  65. const materials = new Map<string, Material>(materialList.map(m => [m.ticker, m]));
  66. const oneWayHours = travel(graph.edges, planet.system_id, cx);
  67. const planInput = new Counter();
  68. const planOutput = new Counter();
  69. for (const building of plan.plan_details.plan_data.buildings) {
  70. const expertise = buildingExpertise.get(building.name)!;
  71. const experts = planExperts.get(expertise)!;
  72. const {input, output} = calcBuilding(recipes, building, plan.plan_details.plan_cogc === expertise, experts, planet);
  73. planInput.update(input);
  74. planOutput.update(output);
  75. }
  76. const net = new Counter();
  77. net.update(planOutput);
  78. for (const [key, amount] of planInput.entries())
  79. net.add(key, -amount);
  80. renderTarget.innerHTML = `
  81. <table>
  82. <tr>
  83. <th></th>
  84. <th>in</th>
  85. <th>out</th>
  86. <th>net</th>
  87. <th>daily traded</th>
  88. <th>% of traded</th>
  89. </tr>
  90. ${[...net.entries()].map(([mat, netAmount]) => {
  91. const traded = dailyTraded.get(mat)!;
  92. const colorPct = Math.min(Math.abs(netAmount) / traded * 500, 100);
  93. const color = `color-mix(in xyz, #f80 ${colorPct}%, #0aa)`;
  94. return `<tr>
  95. <td>${mat}</td>
  96. <td>${formatNumber(planInput.get(mat))}</td>
  97. <td>${formatNumber(planOutput.get(mat))}</td>
  98. <td>${formatNumber(netAmount)}</td>
  99. <td>${wholeFmt.format(traded)}</td>
  100. <td style="color: ${color}">${pctFmt.format(netAmount / traded)}</td>
  101. </tr>`;
  102. }).join('')}
  103. </table>`;
  104. if (oneWayHours !== null) {
  105. const {shippingBottleneckType, shippingBottleneckAmount} = shippingBottleneck(materials, net);
  106. const roundTripHours = oneWayHours * 2;
  107. const scbThroughput = 24 / roundTripHours * 500;
  108. renderTarget.innerHTML += `
  109. <p>
  110. shipping bottleneck: ${formatNumber(shippingBottleneckAmount)} (${shippingBottleneckType})
  111. </p>
  112. <p>
  113. one-way to/from ${cx}: ${formatNumber(oneWayHours)} hours
  114. <br>round-trip to ${cx}: ${formatNumber(roundTripHours)} hours
  115. <br>non-stop SCB shipping throughput per day: 24 ÷ ${formatNumber(roundTripHours)} × 500 = ${formatNumber(scbThroughput)}
  116. <br>non-stop SCBs needed: ${formatNumber(shippingBottleneckAmount)} ÷ ${formatNumber(scbThroughput)}
  117. = ${formatNumber(shippingBottleneckAmount / scbThroughput)}
  118. </p>
  119. `;
  120. }
  121. }
  122. function travel(edges: Edge[], system: string, cx: string): number | null {
  123. const {parsecs, jumps} = ftlDistance(edges, system, cx);
  124. if (jumps === 0)
  125. return null;
  126. // SCB JMPs at 4pc/hr. RCT CHRGs in 7m30s. 30m DEP, 1.5hr APP+(TO/LAND)
  127. return Math.round(parsecs / 4 + (jumps - 1) * 0.125 + 2);
  128. }
  129. /** dijkstra to find the shortest FTL route from system to CX */
  130. function ftlDistance(edges: Edge[], system: string, cx: string): {parsecs: number; jumps: number} {
  131. const cxSystems: Record<string, string> = {
  132. 'AI1': '8ecf9670ba070d78cfb5537e8d9f1b6c',
  133. 'CI1': '92029ff27c1abe932bd2c61ee4c492c7',
  134. 'CI2': 'a4ba8b12739da65efc2b518703652ee1',
  135. 'IC1': 'f2f57766ebaca9d69efae41ccf4d8853',
  136. 'NC1': '49b6615d39ccba05752b3be77b2ebf36',
  137. 'NC2': 'afda9bea7f948f4a066a8882cdfa9055',
  138. };
  139. const destination = cxSystems[cx]!;
  140. if (destination === system)
  141. return {parsecs: 0, jumps: 0};
  142. const adjacency = new Map<string, Array<{to: string; parsecs: number}>>();
  143. for (const edge of edges) {
  144. const startNeighbors = adjacency.get(edge.start) ?? [];
  145. startNeighbors.push({to: edge.end, parsecs: edge.distance});
  146. adjacency.set(edge.start, startNeighbors);
  147. const endNeighbors = adjacency.get(edge.end) ?? [];
  148. endNeighbors.push({to: edge.start, parsecs: edge.distance});
  149. adjacency.set(edge.end, endNeighbors);
  150. }
  151. const best = new Map<string, number>([[system, 0]]);
  152. const frontier: Array<{node: string; parsecs: number; jumps: number}> = [{node: system, parsecs: 0, jumps: 0}];
  153. while (frontier.length > 0) {
  154. frontier.sort((a, b) => a.parsecs - b.parsecs);
  155. const current = frontier.shift()!;
  156. if (current.node === destination)
  157. return {parsecs: current.parsecs, jumps: current.jumps};
  158. if (current.parsecs !== best.get(current.node))
  159. continue;
  160. for (const neighbor of adjacency.get(current.node) ?? []) {
  161. const totalDistance = current.parsecs + neighbor.parsecs;
  162. if (totalDistance >= (best.get(neighbor.to) ?? Number.POSITIVE_INFINITY))
  163. continue;
  164. best.set(neighbor.to, totalDistance);
  165. frontier.push({node: neighbor.to, parsecs: totalDistance, jumps: current.jumps + 1});
  166. }
  167. }
  168. throw new Error(`no route from ${system} to ${cx}`);
  169. }
  170. function calcBuilding(recipes: Map<string, Recipe>, building: PlanBuilding, cogc: boolean, experts: number,
  171. planet: Planet): {input: Counter, output: Counter} {
  172. let efficiency = expertBonus[experts];
  173. if (cogc)
  174. efficiency *= 1.25;
  175. const input = new Counter();
  176. const output = new Counter();
  177. if (['COL', 'EXT', 'RIG'].includes(building.name)) {
  178. const total = building.active_recipes.reduce((sum, recipe) => sum + recipe.amount, 0);
  179. for (const recipe of building.active_recipes) {
  180. const [buildingName, recipeName] = recipe.recipeid.split('#');
  181. if (buildingName != building.name)
  182. throw new Error(`recipe ${recipe.recipeid} doesn't match building ${building.name}`);
  183. const resource = planet.resources.find((r) => r.material_ticker === recipeName);
  184. if (resource === undefined)
  185. throw new Error(`resource ${recipeName} not found on planet ${planet.planet_natural_id}`);
  186. const dailyOutput = resource.daily_extraction * efficiency * building.amount * recipe.amount / total;
  187. output.add(recipeName, dailyOutput);
  188. }
  189. } else {
  190. const totalMs = building.active_recipes.reduce((sum, recipe) => {
  191. const recipeData = recipes.get(recipe.recipeid);
  192. if (recipeData === undefined)
  193. throw new Error(`recipe ${recipe.recipeid} not found`);
  194. return sum + recipeData.time_ms * recipe.amount;
  195. }, 0);
  196. for (const recipe of building.active_recipes) {
  197. const recipeData = recipes.get(recipe.recipeid)!;
  198. let runsPerDay = 24 * 60 * 60 * 1000 / totalMs * efficiency * building.amount * recipe.amount;
  199. if (['FRM', 'ORC'].includes(building.name))
  200. runsPerDay *= 1 + planet.fertility * (10 / 33);
  201. for (const inputMaterial of recipeData.inputs)
  202. input.add(inputMaterial.material_ticker, inputMaterial.material_amount * runsPerDay);
  203. for (const outputMaterial of recipeData.outputs)
  204. output.add(outputMaterial.material_ticker, outputMaterial.material_amount * runsPerDay);
  205. }
  206. }
  207. return {input, output};
  208. }
  209. function shippingBottleneck(materials: Map<string, Material>, net: Counter):
  210. {shippingBottleneckType: string, shippingBottleneckAmount: number} {
  211. let importWeight = 0, importVol = 0;
  212. let exportWeight = 0, exportVol = 0;
  213. for (const [mat, amount] of net.entries()) {
  214. const material = materials.get(mat)!;
  215. if (amount < 0) {
  216. importWeight -= amount * material.weight;
  217. importVol -= amount * material.volume;
  218. } else if (amount > 0) {
  219. exportWeight += amount * material.weight;
  220. exportVol += amount * material.volume;
  221. }
  222. }
  223. const bottlenecks = [
  224. {shippingBottleneckType: 'import weight', shippingBottleneckAmount: importWeight},
  225. {shippingBottleneckType: 'import volume', shippingBottleneckAmount: importVol},
  226. {shippingBottleneckType: 'export weight', shippingBottleneckAmount: exportWeight},
  227. {shippingBottleneckType: 'export volume', shippingBottleneckAmount: exportVol},
  228. ];
  229. bottlenecks.sort((a, b) => b.shippingBottleneckAmount - a.shippingBottleneckAmount);
  230. return bottlenecks[0];
  231. }
  232. const numberFmt = new Intl.NumberFormat(undefined, {maximumFractionDigits: 2});
  233. const wholeFmt = new Intl.NumberFormat(undefined, {maximumFractionDigits: 0});
  234. const pctFmt = new Intl.NumberFormat(undefined, {style: 'percent', maximumFractionDigits: 2});
  235. function formatNumber(num: number): string {
  236. if (num === 0)
  237. return '';
  238. return numberFmt.format(num);
  239. }
  240. const expertBonus: Record<number, number> = {
  241. 0: 1,
  242. 1: 1.0306,
  243. 2: 1.0696,
  244. 3: 1.1248,
  245. 4: 1.1974,
  246. 5: 1.284,
  247. } as const;
  248. interface Plan {
  249. plan_details: {
  250. planet_natural_id: string
  251. plan_cogc: Expertise
  252. plan_data: {
  253. buildings: Array<PlanBuilding>
  254. experts: Array<{type: string; amount: number}>
  255. }
  256. }
  257. }
  258. interface PlanBuilding {
  259. name: string
  260. active_recipes: Array<{amount: number; recipeid: string}>
  261. amount: number
  262. }
  263. interface Building {
  264. building_ticker: string
  265. expertise: Expertise
  266. }
  267. type Expertise = 'AGRICULTURE' | 'CHEMISTRY' | 'CONSTRUCTION' | 'ELECTRONICS' | 'FOOD_INDUSTRIES' | 'FUEL_REFINING' |
  268. 'MANUFACTURING' | 'METALLURGY' | 'RESOURCE_EXTRACTION';
  269. interface Recipe {
  270. recipe_id: string
  271. inputs: Array<{material_ticker: string; material_amount: number}>
  272. outputs: Array<{material_ticker: string; material_amount: number}>
  273. time_ms: number
  274. }
  275. interface Exchange {
  276. ticker: string
  277. exchange_code: string
  278. sum_traded_7d: number // avg_traded_7d is nonsense
  279. }
  280. interface Material {
  281. ticker: string
  282. weight: number
  283. volume: number
  284. }
  285. interface Graph {
  286. edges: Array<Edge>
  287. }
  288. interface Edge {
  289. start: string
  290. end: string
  291. distance: number
  292. }
  293. interface Planet {
  294. planet_natural_id: string
  295. system_id: string
  296. fertility: number
  297. resources: Array<{
  298. material_ticker: string
  299. daily_extraction: number
  300. }>
  301. }