plan.ts 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  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, graph]: [Plan, Building[], Recipe[], Exchange[], Graph] = await Promise.all([
  50. cachedFetchJSON('https://api.prunplanner.org/planning/shared/' + shareUUID),
  51. cachedFetchJSON('https://api.prunplanner.org/data/buildings/'),
  52. cachedFetchJSON('https://api.prunplanner.org/data/recipes/'),
  53. cachedFetchJSON('https://api.prunplanner.org/data/exchanges/'),
  54. cachedFetchJSON('/graph_data.json'),
  55. ]);
  56. const planet: Planet = await cachedFetchJSON(`https://api.prunplanner.org/data/planet/${plan.plan_details.planet_natural_id}/`);
  57. const planExperts = new Map<Expertise, number>(plan.plan_details.plan_data.experts.map(
  58. e => [e.type.toUpperCase() as Expertise, e.amount]));
  59. const buildingExpertise = new Map<string, Expertise>(buildings.map(b => [b.building_ticker, b.expertise]));
  60. const recipes = new Map<string, Recipe>(recipeList.map(r => [r.recipe_id, r]));
  61. const dailyTraded = new Map<string, number>(exchanges.filter((ex) => ex.exchange_code === cx)
  62. .map((ex) => [ex.ticker, ex.sum_traded_7d / 7]));
  63. travel(graph.edges, planet.system_id, cx);
  64. const planInput = new Counter();
  65. const planOutput = new Counter();
  66. for (const building of plan.plan_details.plan_data.buildings) {
  67. const expertise = buildingExpertise.get(building.name)!;
  68. const experts = planExperts.get(expertise)!;
  69. const {input, output} = calcBuilding(recipes, building, plan.plan_details.plan_cogc === expertise, experts, planet);
  70. planInput.update(input);
  71. planOutput.update(output);
  72. }
  73. const net = new Counter();
  74. net.update(planOutput);
  75. for (const [key, amount] of planInput.entries())
  76. net.add(key, -amount);
  77. renderTarget.innerHTML = `
  78. <table>
  79. <tr>
  80. <th></th>
  81. <th>in</th>
  82. <th>out</th>
  83. <th>net</th>
  84. <th>daily traded</th>
  85. <th>% of traded</th>
  86. </tr>
  87. ${[...net.entries()].map(([mat, netAmount]) => {
  88. const traded = dailyTraded.get(mat)!;
  89. const colorPct = Math.min(Math.abs(netAmount) / traded * 500, 100);
  90. const color = `color-mix(in xyz, #f80 ${colorPct}%, #0aa)`;
  91. return `<tr>
  92. <td>${mat}</td>
  93. <td>${formatNumber(planInput.get(mat))}</td>
  94. <td>${formatNumber(planOutput.get(mat))}</td>
  95. <td>${formatNumber(netAmount)}</td>
  96. <td>${wholeFmt.format(traded)}</td>
  97. <td style="color: ${color}">${pctFmt.format(netAmount / traded)}</td>
  98. </tr>`;
  99. }).join('')}
  100. </table>
  101. `;
  102. }
  103. function travel(edges: Edge[], system: string, cx: string): number {
  104. const {parsecs, jumps} = ftlDistance(edges, system, cx);
  105. // SCB JMPs at 4pc/hr. RCT CHRGs in 7m30s. 30m DEP, 1.5hr APP+(TO/LAND)
  106. return parsecs / 4 + (jumps - 1) * 0.125 + 2;
  107. }
  108. /** dijkstra to find the shortest FTL route from system to CX */
  109. function ftlDistance(edges: Edge[], system: string, cx: string): {parsecs: number; jumps: number} {
  110. const cxSystems: Record<string, string> = {
  111. 'AI1': '8ecf9670ba070d78cfb5537e8d9f1b6c',
  112. 'CI1': '92029ff27c1abe932bd2c61ee4c492c7',
  113. 'CI2': 'a4ba8b12739da65efc2b518703652ee1',
  114. 'IC1': 'f2f57766ebaca9d69efae41ccf4d8853',
  115. 'NC1': '49b6615d39ccba05752b3be77b2ebf36',
  116. 'NC2': 'afda9bea7f948f4a066a8882cdfa9055',
  117. };
  118. const destination = cxSystems[cx]!;
  119. if (destination === system)
  120. return {parsecs: 0, jumps: 0};
  121. const adjacency = new Map<string, Array<{to: string; parsecs: number}>>();
  122. for (const edge of edges) {
  123. const startNeighbors = adjacency.get(edge.start) ?? [];
  124. startNeighbors.push({to: edge.end, parsecs: edge.distance});
  125. adjacency.set(edge.start, startNeighbors);
  126. const endNeighbors = adjacency.get(edge.end) ?? [];
  127. endNeighbors.push({to: edge.start, parsecs: edge.distance});
  128. adjacency.set(edge.end, endNeighbors);
  129. }
  130. const best = new Map<string, number>([[system, 0]]);
  131. const frontier: Array<{node: string; parsecs: number; jumps: number}> = [{node: system, parsecs: 0, jumps: 0}];
  132. while (frontier.length > 0) {
  133. frontier.sort((a, b) => a.parsecs - b.parsecs);
  134. const current = frontier.shift()!;
  135. if (current.node === destination)
  136. return {parsecs: current.parsecs, jumps: current.jumps};
  137. if (current.parsecs !== best.get(current.node))
  138. continue;
  139. for (const neighbor of adjacency.get(current.node) ?? []) {
  140. const totalDistance = current.parsecs + neighbor.parsecs;
  141. if (totalDistance >= (best.get(neighbor.to) ?? Number.POSITIVE_INFINITY))
  142. continue;
  143. best.set(neighbor.to, totalDistance);
  144. frontier.push({node: neighbor.to, parsecs: totalDistance, jumps: current.jumps + 1});
  145. }
  146. }
  147. throw new Error(`no route from ${system} to ${cx}`);
  148. }
  149. function calcBuilding(recipes: Map<string, Recipe>, building: PlanBuilding, cogc: boolean, experts: number,
  150. planet: Planet): {input: Counter, output: Counter} {
  151. let efficiency = expertBonus[experts];
  152. if (cogc)
  153. efficiency *= 1.25;
  154. const input = new Counter();
  155. const output = new Counter();
  156. if (['COL', 'EXT', 'RIG'].includes(building.name)) {
  157. const total = building.active_recipes.reduce((sum, recipe) => sum + recipe.amount, 0);
  158. for (const recipe of building.active_recipes) {
  159. const [buildingName, recipeName] = recipe.recipeid.split('#');
  160. if (buildingName != building.name)
  161. throw new Error(`recipe ${recipe.recipeid} doesn't match building ${building.name}`);
  162. const resource = planet.resources.find((r) => r.material_ticker === recipeName);
  163. if (resource === undefined)
  164. throw new Error(`resource ${recipeName} not found on planet ${planet.planet_natural_id}`);
  165. const dailyOutput = resource.daily_extraction * efficiency * building.amount * recipe.amount / total;
  166. output.add(recipeName, dailyOutput);
  167. }
  168. } else {
  169. const totalMs = building.active_recipes.reduce((sum, recipe) => {
  170. const recipeData = recipes.get(recipe.recipeid);
  171. if (recipeData === undefined)
  172. throw new Error(`recipe ${recipe.recipeid} not found`);
  173. return sum + recipeData.time_ms * recipe.amount;
  174. }, 0);
  175. for (const recipe of building.active_recipes) {
  176. const recipeData = recipes.get(recipe.recipeid)!;
  177. let runsPerDay = 24 * 60 * 60 * 1000 / totalMs * efficiency * building.amount * recipe.amount;
  178. if (['FRM', 'ORC'].includes(building.name))
  179. runsPerDay *= 1 + planet.fertility * (10 / 33);
  180. for (const inputMaterial of recipeData.inputs)
  181. input.add(inputMaterial.material_ticker, inputMaterial.material_amount * runsPerDay);
  182. for (const outputMaterial of recipeData.outputs)
  183. output.add(outputMaterial.material_ticker, outputMaterial.material_amount * runsPerDay);
  184. }
  185. }
  186. return {input, output};
  187. }
  188. const numberFmt = new Intl.NumberFormat(undefined, {maximumFractionDigits: 2});
  189. const wholeFmt = new Intl.NumberFormat(undefined, {maximumFractionDigits: 0});
  190. const pctFmt = new Intl.NumberFormat(undefined, {style: 'percent', maximumFractionDigits: 2});
  191. function formatNumber(num: number): string {
  192. if (num === 0)
  193. return '';
  194. return numberFmt.format(num);
  195. }
  196. const expertBonus: Record<number, number> = {
  197. 0: 1,
  198. 1: 1.0306,
  199. 2: 1.0696,
  200. 3: 1.1248,
  201. 4: 1.1974,
  202. 5: 1.284,
  203. } as const;
  204. interface Plan {
  205. plan_details: {
  206. planet_natural_id: string
  207. plan_cogc: Expertise
  208. plan_data: {
  209. buildings: Array<PlanBuilding>
  210. experts: Array<{type: string; amount: number}>
  211. }
  212. }
  213. }
  214. interface PlanBuilding {
  215. name: string
  216. active_recipes: Array<{amount: number; recipeid: string}>
  217. amount: number
  218. }
  219. interface Building {
  220. building_ticker: string
  221. expertise: Expertise
  222. }
  223. type Expertise = 'AGRICULTURE' | 'CHEMISTRY' | 'CONSTRUCTION' | 'ELECTRONICS' | 'FOOD_INDUSTRIES' | 'FUEL_REFINING' |
  224. 'MANUFACTURING' | 'METALLURGY' | 'RESOURCE_EXTRACTION';
  225. interface Recipe {
  226. recipe_id: string
  227. inputs: Array<{material_ticker: string; material_amount: number}>
  228. outputs: Array<{material_ticker: string; material_amount: number}>
  229. time_ms: number
  230. }
  231. interface Exchange {
  232. ticker: string
  233. exchange_code: string
  234. sum_traded_7d: number // avg_traded_7d is nonsense
  235. }
  236. interface Graph {
  237. edges: Array<Edge>
  238. }
  239. interface Edge {
  240. start: string
  241. end: string
  242. distance: number
  243. }
  244. interface Planet {
  245. planet_natural_id: string
  246. system_id: string
  247. fertility: number
  248. resources: Array<{
  249. material_ticker: string
  250. daily_extraction: number
  251. }>
  252. }